@bubblebrain-ai/bubble 0.0.9 → 0.0.11

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 (153) hide show
  1. package/dist/agent.d.ts +1 -0
  2. package/dist/agent.js +5 -0
  3. package/dist/cli.d.ts +10 -0
  4. package/dist/cli.js +31 -3
  5. package/dist/feedback/collect.d.ts +7 -0
  6. package/dist/feedback/collect.js +119 -0
  7. package/dist/feedback/config.d.ts +14 -0
  8. package/dist/feedback/config.js +16 -0
  9. package/dist/feedback/redact.d.ts +1 -0
  10. package/dist/feedback/redact.js +25 -0
  11. package/dist/feedback/submit.d.ts +6 -0
  12. package/dist/feedback/submit.js +43 -0
  13. package/dist/feedback/types.d.ts +22 -0
  14. package/dist/feishu/agent-host/approval-card.d.ts +11 -0
  15. package/dist/feishu/agent-host/approval-card.js +46 -0
  16. package/dist/feishu/agent-host/approval-ui.d.ts +59 -0
  17. package/dist/feishu/agent-host/approval-ui.js +214 -0
  18. package/dist/feishu/agent-host/run-driver.d.ts +51 -0
  19. package/dist/feishu/agent-host/run-driver.js +295 -0
  20. package/dist/feishu/agent-host/runtime-deps.d.ts +33 -0
  21. package/dist/feishu/agent-host/runtime-deps.js +8 -0
  22. package/dist/feishu/card/budget.d.ts +40 -0
  23. package/dist/feishu/card/budget.js +134 -0
  24. package/dist/feishu/card/renderer.d.ts +29 -0
  25. package/dist/feishu/card/renderer.js +245 -0
  26. package/dist/feishu/card/run-state-types.d.ts +49 -0
  27. package/dist/feishu/card/run-state-types.js +15 -0
  28. package/dist/feishu/card/run-state.d.ts +21 -0
  29. package/dist/feishu/card/run-state.js +217 -0
  30. package/dist/feishu/channel/channel.d.ts +52 -0
  31. package/dist/feishu/channel/channel.js +74 -0
  32. package/dist/feishu/config.d.ts +24 -0
  33. package/dist/feishu/config.js +97 -0
  34. package/dist/feishu/format.d.ts +6 -0
  35. package/dist/feishu/format.js +14 -0
  36. package/dist/feishu/index.d.ts +4 -0
  37. package/dist/feishu/index.js +4 -0
  38. package/dist/feishu/logger.d.ts +31 -0
  39. package/dist/feishu/logger.js +62 -0
  40. package/dist/feishu/paths.d.ts +12 -0
  41. package/dist/feishu/paths.js +38 -0
  42. package/dist/feishu/process-registry.d.ts +29 -0
  43. package/dist/feishu/process-registry.js +90 -0
  44. package/dist/feishu/router/commands.d.ts +38 -0
  45. package/dist/feishu/router/commands.js +285 -0
  46. package/dist/feishu/router/event-router.d.ts +40 -0
  47. package/dist/feishu/router/event-router.js +208 -0
  48. package/dist/feishu/router/whitelist.d.ts +23 -0
  49. package/dist/feishu/router/whitelist.js +20 -0
  50. package/dist/feishu/runtime/active-runs.d.ts +32 -0
  51. package/dist/feishu/runtime/active-runs.js +84 -0
  52. package/dist/feishu/runtime/pending-queue.d.ts +36 -0
  53. package/dist/feishu/runtime/pending-queue.js +98 -0
  54. package/dist/feishu/runtime/process-pool.d.ts +29 -0
  55. package/dist/feishu/runtime/process-pool.js +49 -0
  56. package/dist/feishu/schema.d.ts +17 -0
  57. package/dist/feishu/schema.js +252 -0
  58. package/dist/feishu/scope/scope-registry.d.ts +39 -0
  59. package/dist/feishu/scope/scope-registry.js +148 -0
  60. package/dist/feishu/scope/session-binder.d.ts +44 -0
  61. package/dist/feishu/scope/session-binder.js +100 -0
  62. package/dist/feishu/scope/session-store.d.ts +24 -0
  63. package/dist/feishu/scope/session-store.js +73 -0
  64. package/dist/feishu/secrets.d.ts +37 -0
  65. package/dist/feishu/secrets.js +129 -0
  66. package/dist/feishu/serve.d.ts +12 -0
  67. package/dist/feishu/serve.js +288 -0
  68. package/dist/feishu/types.d.ts +75 -0
  69. package/dist/feishu/types.js +23 -0
  70. package/dist/feishu/wizard.d.ts +24 -0
  71. package/dist/feishu/wizard.js +121 -0
  72. package/dist/main.js +78 -29
  73. package/dist/model-catalog.js +3 -0
  74. package/dist/session.d.ts +11 -0
  75. package/dist/session.js +88 -2
  76. package/dist/slash-commands/commands.js +13 -0
  77. package/dist/slash-commands/feishu.d.ts +17 -0
  78. package/dist/slash-commands/feishu.js +400 -0
  79. package/dist/slash-commands/types.d.ts +3 -1
  80. package/dist/tui-ink/app.js +218 -60
  81. package/dist/tui-ink/code-highlight.js +2 -3
  82. package/dist/tui-ink/detect-theme.d.ts +1 -18
  83. package/dist/tui-ink/detect-theme.js +1 -37
  84. package/dist/tui-ink/display-history.d.ts +20 -3
  85. package/dist/tui-ink/display-history.js +26 -27
  86. package/dist/tui-ink/feedback-dialog.d.ts +19 -0
  87. package/dist/tui-ink/feedback-dialog.js +123 -0
  88. package/dist/tui-ink/feishu-setup-picker.d.ts +5 -0
  89. package/dist/tui-ink/feishu-setup-picker.js +261 -0
  90. package/dist/tui-ink/input-box.d.ts +3 -0
  91. package/dist/tui-ink/input-box.js +27 -0
  92. package/dist/tui-ink/input-history.js +3 -5
  93. package/dist/tui-ink/markdown.d.ts +32 -0
  94. package/dist/tui-ink/markdown.js +111 -4
  95. package/dist/tui-ink/message-list.d.ts +1 -6
  96. package/dist/tui-ink/message-list.js +85 -34
  97. package/dist/tui-ink/model-picker.js +1 -4
  98. package/dist/tui-ink/run-session-picker.d.ts +10 -0
  99. package/dist/tui-ink/run-session-picker.js +22 -0
  100. package/dist/tui-ink/run.js +7 -2
  101. package/dist/tui-ink/session-picker.d.ts +10 -0
  102. package/dist/tui-ink/session-picker.js +112 -0
  103. package/dist/tui-ink/terminal-mouse.d.ts +4 -0
  104. package/dist/tui-ink/terminal-mouse.js +23 -0
  105. package/dist/tui-ink/trace-groups.js +25 -2
  106. package/dist/tui-ink/welcome.js +2 -4
  107. package/package.json +4 -5
  108. package/dist/tui/clipboard.d.ts +0 -1
  109. package/dist/tui/clipboard.js +0 -53
  110. package/dist/tui/display-history.d.ts +0 -44
  111. package/dist/tui/display-history.js +0 -243
  112. package/dist/tui/escape-confirmation.d.ts +0 -15
  113. package/dist/tui/escape-confirmation.js +0 -30
  114. package/dist/tui/file-mentions.d.ts +0 -29
  115. package/dist/tui/file-mentions.js +0 -174
  116. package/dist/tui/global-key-router.d.ts +0 -3
  117. package/dist/tui/global-key-router.js +0 -87
  118. package/dist/tui/image-paste.d.ts +0 -95
  119. package/dist/tui/image-paste.js +0 -505
  120. package/dist/tui/markdown-inline.d.ts +0 -22
  121. package/dist/tui/markdown-inline.js +0 -68
  122. package/dist/tui/markdown-theme-rules.d.ts +0 -23
  123. package/dist/tui/markdown-theme-rules.js +0 -164
  124. package/dist/tui/markdown-theme.d.ts +0 -5
  125. package/dist/tui/markdown-theme.js +0 -27
  126. package/dist/tui/opencode-spinner.d.ts +0 -21
  127. package/dist/tui/opencode-spinner.js +0 -216
  128. package/dist/tui/prompt-keybindings.d.ts +0 -42
  129. package/dist/tui/prompt-keybindings.js +0 -35
  130. package/dist/tui/recent-activity.d.ts +0 -8
  131. package/dist/tui/recent-activity.js +0 -71
  132. package/dist/tui/render-signature.d.ts +0 -1
  133. package/dist/tui/render-signature.js +0 -7
  134. package/dist/tui/run.d.ts +0 -38
  135. package/dist/tui/run.js +0 -6996
  136. package/dist/tui/sidebar-mcp.d.ts +0 -31
  137. package/dist/tui/sidebar-mcp.js +0 -62
  138. package/dist/tui/sidebar-state.d.ts +0 -12
  139. package/dist/tui/sidebar-state.js +0 -69
  140. package/dist/tui/streaming-tool-args.d.ts +0 -15
  141. package/dist/tui/streaming-tool-args.js +0 -30
  142. package/dist/tui/tool-renderers/fallback.d.ts +0 -2
  143. package/dist/tui/tool-renderers/fallback.js +0 -75
  144. package/dist/tui/tool-renderers/registry.d.ts +0 -3
  145. package/dist/tui/tool-renderers/registry.js +0 -11
  146. package/dist/tui/tool-renderers/subagent.d.ts +0 -2
  147. package/dist/tui/tool-renderers/subagent.js +0 -114
  148. package/dist/tui/tool-renderers/types.d.ts +0 -36
  149. package/dist/tui/tool-renderers/write-preview.d.ts +0 -12
  150. package/dist/tui/tool-renderers/write-preview.js +0 -30
  151. package/dist/tui/tool-renderers/write.d.ts +0 -6
  152. package/dist/tui/tool-renderers/write.js +0 -88
  153. /package/dist/{tui/tool-renderers → feedback}/types.js +0 -0
@@ -0,0 +1,148 @@
1
+ /**
2
+ * scopes.json reader/writer.
3
+ *
4
+ * `cwd` here is the *initial* cwd written by the wizard. The truth source
5
+ * for "current cwd" after first use is sessions.json. See SessionStore.
6
+ *
7
+ * `/feishu bind` is invoked from the TUI process, but the running serve
8
+ * is a separate spawned subprocess — so the serve's in-memory cache would
9
+ * go stale the moment the TUI updates scopes.json. To bridge that, every
10
+ * read path stats the file and reloads if its mtime is newer than what
11
+ * we have cached. The cost is one stat() call per inbound message, which
12
+ * is negligible compared to LLM latency.
13
+ */
14
+ import { existsSync, readFileSync, statSync, writeFileSync } from "node:fs";
15
+ import { getScopesPath } from "../paths.js";
16
+ import { validateScopesFile } from "../schema.js";
17
+ export class ScopeRegistry {
18
+ file;
19
+ lastMtimeMs;
20
+ constructor(file, mtimeMs) {
21
+ this.file = file;
22
+ this.lastMtimeMs = mtimeMs;
23
+ }
24
+ static load() {
25
+ return loadFromDisk();
26
+ }
27
+ /**
28
+ * Reload from disk if scopes.json has been modified since our cached copy.
29
+ * Called from every read path so cross-process writes (e.g. `/feishu bind`
30
+ * from the TUI updating the file while the serve subprocess is running)
31
+ * become visible without restart.
32
+ */
33
+ syncFromDiskIfChanged() {
34
+ const path = getScopesPath();
35
+ if (!existsSync(path)) {
36
+ if (Object.keys(this.file.scopes).length > 0) {
37
+ this.file = { version: 1, scopes: {} };
38
+ this.lastMtimeMs = 0;
39
+ }
40
+ return;
41
+ }
42
+ let mtime;
43
+ try {
44
+ mtime = statSync(path).mtimeMs;
45
+ }
46
+ catch {
47
+ return;
48
+ }
49
+ if (mtime <= this.lastMtimeMs)
50
+ return;
51
+ try {
52
+ const next = loadFromDisk();
53
+ this.file = next.file;
54
+ this.lastMtimeMs = next.lastMtimeMs;
55
+ }
56
+ catch {
57
+ // Bad JSON or schema mid-flight (e.g. user editing scopes.json by hand).
58
+ // Keep the previous cached copy rather than tearing down the service.
59
+ }
60
+ }
61
+ save() {
62
+ writeFileSync(getScopesPath(), JSON.stringify(this.file, null, 2), { encoding: "utf8", mode: 0o600 });
63
+ // Refresh cached mtime so the next read doesn't see *our own* write as a stale-cache miss.
64
+ try {
65
+ this.lastMtimeMs = statSync(getScopesPath()).mtimeMs;
66
+ }
67
+ catch {
68
+ this.lastMtimeMs = Date.now();
69
+ }
70
+ }
71
+ get(chatId) {
72
+ this.syncFromDiskIfChanged();
73
+ return this.file.scopes[chatId];
74
+ }
75
+ has(chatId) {
76
+ this.syncFromDiskIfChanged();
77
+ return chatId in this.file.scopes;
78
+ }
79
+ list() {
80
+ this.syncFromDiskIfChanged();
81
+ return Object.entries(this.file.scopes).map(([chatId, scope]) => ({ chatId, scope }));
82
+ }
83
+ upsert(chatId, scope) {
84
+ this.syncFromDiskIfChanged();
85
+ this.file.scopes[chatId] = scope;
86
+ this.save();
87
+ }
88
+ remove(chatId) {
89
+ this.syncFromDiskIfChanged();
90
+ if (!(chatId in this.file.scopes))
91
+ return false;
92
+ delete this.file.scopes[chatId];
93
+ this.save();
94
+ return true;
95
+ }
96
+ touch(chatId, when = Date.now()) {
97
+ this.syncFromDiskIfChanged();
98
+ const scope = this.file.scopes[chatId];
99
+ if (!scope)
100
+ return;
101
+ scope.lastActiveAt = when;
102
+ this.save();
103
+ }
104
+ isUserAllowed(chatId, userId) {
105
+ this.syncFromDiskIfChanged();
106
+ const scope = this.file.scopes[chatId];
107
+ if (!scope)
108
+ return false;
109
+ return scope.allowedUsers.includes(userId);
110
+ }
111
+ isUserAdmin(chatId, userId) {
112
+ this.syncFromDiskIfChanged();
113
+ const scope = this.file.scopes[chatId];
114
+ if (!scope)
115
+ return false;
116
+ return scope.admins.includes(userId);
117
+ }
118
+ }
119
+ function loadFromDisk() {
120
+ const path = getScopesPath();
121
+ if (!existsSync(path)) {
122
+ return new ScopeRegistry({ version: 1, scopes: {} }, 0);
123
+ }
124
+ const raw = readFileSync(path, "utf8");
125
+ if (!raw.trim()) {
126
+ return new ScopeRegistry({ version: 1, scopes: {} }, getMtime(path));
127
+ }
128
+ let parsed;
129
+ try {
130
+ parsed = JSON.parse(raw);
131
+ }
132
+ catch {
133
+ throw new Error(`scopes.json is not valid JSON at ${path}`);
134
+ }
135
+ const result = validateScopesFile(parsed);
136
+ if (!result.ok || !result.value) {
137
+ throw new Error(`scopes.json invalid:\n - ${result.errors.join("\n - ")}`);
138
+ }
139
+ return new ScopeRegistry(result.value, getMtime(path));
140
+ }
141
+ function getMtime(path) {
142
+ try {
143
+ return statSync(path).mtimeMs;
144
+ }
145
+ catch {
146
+ return 0;
147
+ }
148
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Bridges scopeKey ↔ on-disk SessionManager (reuses ~/.bubble/sessions/<safe_cwd>/<name>.jsonl).
3
+ *
4
+ * SessionStore (sessions.json) is the index: scopeKey → { sessionFile, cwd, mode }.
5
+ * On first traffic for a scope we bootstrap from ScopeConfig.cwd.
6
+ * /cd and /new both archive (keep file on disk, just stop pointing at it) and start fresh.
7
+ */
8
+ import { SessionManager, type SessionSummary } from "../../session.js";
9
+ import type { PermissionMode } from "../../types.js";
10
+ import type { SessionStore } from "./session-store.js";
11
+ import type { ScopeKey } from "../types.js";
12
+ export interface OpenedSession {
13
+ manager: SessionManager;
14
+ cwd: string;
15
+ permissionMode: PermissionMode;
16
+ /** True iff this call created a brand-new session file (no prior history). */
17
+ fresh: boolean;
18
+ }
19
+ export declare class SessionBinder {
20
+ private readonly store;
21
+ constructor(store: SessionStore);
22
+ /**
23
+ * Get the current session for (scopeKey). If sessions.json has a valid
24
+ * pointer, reuse it. Otherwise, bootstrap a new session with `bootstrapCwd`
25
+ * and `bootstrapMode`.
26
+ */
27
+ openOrBootstrap(scopeKey: ScopeKey, bootstrapCwd: string, bootstrapMode: PermissionMode): OpenedSession;
28
+ /** Start a brand-new session at `cwd` with `mode`, replacing the pointer. */
29
+ createFresh(scopeKey: ScopeKey, cwd: string, mode: PermissionMode): OpenedSession;
30
+ /**
31
+ * /cd: archive (pointer-rotate) and create a new session at newCwd.
32
+ * Permission mode carries over.
33
+ */
34
+ changeCwd(scopeKey: ScopeKey, newCwd: string): OpenedSession;
35
+ /** /resume <name>: re-point sessions.json to an existing file. */
36
+ resumeNamed(scopeKey: ScopeKey, sessionFile: string): OpenedSession | undefined;
37
+ /** List sessions (feishu-prefixed) under `cwd` for the /resume picker. */
38
+ listResumable(cwd: string, limit?: number): SessionSummary[];
39
+ /**
40
+ * Update the persisted permission mode for a scopeKey. Called by /mode and
41
+ * by the agent's onModeUpdate hook.
42
+ */
43
+ setMode(scopeKey: ScopeKey, mode: PermissionMode): void;
44
+ }
@@ -0,0 +1,100 @@
1
+ /**
2
+ * Bridges scopeKey ↔ on-disk SessionManager (reuses ~/.bubble/sessions/<safe_cwd>/<name>.jsonl).
3
+ *
4
+ * SessionStore (sessions.json) is the index: scopeKey → { sessionFile, cwd, mode }.
5
+ * On first traffic for a scope we bootstrap from ScopeConfig.cwd.
6
+ * /cd and /new both archive (keep file on disk, just stop pointing at it) and start fresh.
7
+ */
8
+ import { existsSync } from "node:fs";
9
+ import { SessionManager } from "../../session.js";
10
+ export class SessionBinder {
11
+ store;
12
+ constructor(store) {
13
+ this.store = store;
14
+ }
15
+ /**
16
+ * Get the current session for (scopeKey). If sessions.json has a valid
17
+ * pointer, reuse it. Otherwise, bootstrap a new session with `bootstrapCwd`
18
+ * and `bootstrapMode`.
19
+ */
20
+ openOrBootstrap(scopeKey, bootstrapCwd, bootstrapMode) {
21
+ const entry = this.store.get(scopeKey);
22
+ if (entry && existsSync(entry.sessionFile)) {
23
+ return {
24
+ manager: new SessionManager(entry.sessionFile),
25
+ cwd: entry.cwd,
26
+ permissionMode: entry.permissionMode,
27
+ fresh: false,
28
+ };
29
+ }
30
+ // Bootstrap: pointer is missing or file got removed externally.
31
+ return this.createFresh(scopeKey, bootstrapCwd, bootstrapMode);
32
+ }
33
+ /** Start a brand-new session at `cwd` with `mode`, replacing the pointer. */
34
+ createFresh(scopeKey, cwd, mode) {
35
+ const name = makeSessionName(scopeKey);
36
+ const manager = SessionManager.create(cwd, name);
37
+ // Persist metadata immediately so the on-disk file exists from this point
38
+ // on — otherwise openOrBootstrap() on the next call would see the pointer
39
+ // but no file and re-bootstrap, losing the pointer.
40
+ manager.setMetadata({ cwd });
41
+ const entry = {
42
+ sessionFile: manager.getSessionFile(),
43
+ cwd,
44
+ permissionMode: mode,
45
+ lastActiveAt: Date.now(),
46
+ };
47
+ this.store.upsert(scopeKey, entry);
48
+ return { manager, cwd, permissionMode: mode, fresh: true };
49
+ }
50
+ /**
51
+ * /cd: archive (pointer-rotate) and create a new session at newCwd.
52
+ * Permission mode carries over.
53
+ */
54
+ changeCwd(scopeKey, newCwd) {
55
+ const prev = this.store.get(scopeKey);
56
+ const mode = prev?.permissionMode ?? "default";
57
+ return this.createFresh(scopeKey, newCwd, mode);
58
+ }
59
+ /** /resume <name>: re-point sessions.json to an existing file. */
60
+ resumeNamed(scopeKey, sessionFile) {
61
+ if (!existsSync(sessionFile))
62
+ return undefined;
63
+ const prev = this.store.get(scopeKey);
64
+ const manager = new SessionManager(sessionFile);
65
+ const meta = manager.getMetadata();
66
+ const cwd = meta.cwd ?? prev?.cwd;
67
+ if (!cwd)
68
+ return undefined;
69
+ const mode = prev?.permissionMode ?? "default";
70
+ const entry = {
71
+ sessionFile,
72
+ cwd,
73
+ permissionMode: mode,
74
+ lastActiveAt: Date.now(),
75
+ };
76
+ this.store.upsert(scopeKey, entry);
77
+ return { manager, cwd, permissionMode: mode, fresh: false };
78
+ }
79
+ /** List sessions (feishu-prefixed) under `cwd` for the /resume picker. */
80
+ listResumable(cwd, limit = 10) {
81
+ const all = SessionManager.summarizeSessionsForCwd(cwd);
82
+ const feishu = all.filter((s) => s.name.startsWith("feishu-"));
83
+ return feishu.slice(0, limit);
84
+ }
85
+ /**
86
+ * Update the persisted permission mode for a scopeKey. Called by /mode and
87
+ * by the agent's onModeUpdate hook.
88
+ */
89
+ setMode(scopeKey, mode) {
90
+ this.store.setPermissionMode(scopeKey, mode);
91
+ }
92
+ }
93
+ let SESSION_NAME_COUNTER = 0;
94
+ function makeSessionName(scopeKey) {
95
+ const ts = new Date().toISOString().replace(/[:.]/g, "-");
96
+ const safe = scopeKey.replace(/[^A-Za-z0-9_-]/g, "_");
97
+ // Counter disambiguates back-to-back creates within the same millisecond.
98
+ const seq = (SESSION_NAME_COUNTER++).toString(36);
99
+ return `feishu-${safe}-${ts}-${seq}.jsonl`;
100
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * sessions.json reader/writer. Keyed by `<chatId>:<userId>` (scopeKey).
3
+ *
4
+ * Entries hold the *current* effective cwd + sessionFile + permissionMode
5
+ * for that (chat, user) pair. This is the truth source post-first-use:
6
+ * scopes.json provides only the bootstrap cwd for first-time sessions.
7
+ */
8
+ import type { ScopeKey, SessionEntry, SessionsFile } from "../types.js";
9
+ import type { PermissionMode } from "../../types.js";
10
+ export declare class SessionStore {
11
+ private file;
12
+ constructor(file: SessionsFile);
13
+ static load(): SessionStore;
14
+ save(): void;
15
+ get(scopeKey: ScopeKey): SessionEntry | undefined;
16
+ upsert(scopeKey: ScopeKey, entry: SessionEntry): void;
17
+ remove(scopeKey: ScopeKey): boolean;
18
+ setPermissionMode(scopeKey: ScopeKey, mode: PermissionMode): void;
19
+ touch(scopeKey: ScopeKey, when?: number): void;
20
+ list(): Array<{
21
+ scopeKey: ScopeKey;
22
+ entry: SessionEntry;
23
+ }>;
24
+ }
@@ -0,0 +1,73 @@
1
+ /**
2
+ * sessions.json reader/writer. Keyed by `<chatId>:<userId>` (scopeKey).
3
+ *
4
+ * Entries hold the *current* effective cwd + sessionFile + permissionMode
5
+ * for that (chat, user) pair. This is the truth source post-first-use:
6
+ * scopes.json provides only the bootstrap cwd for first-time sessions.
7
+ */
8
+ import { existsSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { getSessionsPath } from "../paths.js";
10
+ import { validateSessionsFile } from "../schema.js";
11
+ export class SessionStore {
12
+ file;
13
+ constructor(file) {
14
+ this.file = file;
15
+ }
16
+ static load() {
17
+ const path = getSessionsPath();
18
+ if (!existsSync(path)) {
19
+ return new SessionStore({ version: 1, sessions: {} });
20
+ }
21
+ const raw = readFileSync(path, "utf8");
22
+ if (!raw.trim()) {
23
+ return new SessionStore({ version: 1, sessions: {} });
24
+ }
25
+ let parsed;
26
+ try {
27
+ parsed = JSON.parse(raw);
28
+ }
29
+ catch {
30
+ throw new Error(`sessions.json is not valid JSON at ${path}`);
31
+ }
32
+ const result = validateSessionsFile(parsed);
33
+ if (!result.ok || !result.value) {
34
+ throw new Error(`sessions.json invalid:\n - ${result.errors.join("\n - ")}`);
35
+ }
36
+ return new SessionStore(result.value);
37
+ }
38
+ save() {
39
+ writeFileSync(getSessionsPath(), JSON.stringify(this.file, null, 2), { encoding: "utf8", mode: 0o600 });
40
+ }
41
+ get(scopeKey) {
42
+ return this.file.sessions[scopeKey];
43
+ }
44
+ upsert(scopeKey, entry) {
45
+ this.file.sessions[scopeKey] = entry;
46
+ this.save();
47
+ }
48
+ remove(scopeKey) {
49
+ if (!(scopeKey in this.file.sessions))
50
+ return false;
51
+ delete this.file.sessions[scopeKey];
52
+ this.save();
53
+ return true;
54
+ }
55
+ setPermissionMode(scopeKey, mode) {
56
+ const entry = this.file.sessions[scopeKey];
57
+ if (!entry)
58
+ return;
59
+ entry.permissionMode = mode;
60
+ entry.lastActiveAt = Date.now();
61
+ this.save();
62
+ }
63
+ touch(scopeKey, when = Date.now()) {
64
+ const entry = this.file.sessions[scopeKey];
65
+ if (!entry)
66
+ return;
67
+ entry.lastActiveAt = when;
68
+ this.save();
69
+ }
70
+ list() {
71
+ return Object.entries(this.file.sessions).map(([scopeKey, entry]) => ({ scopeKey, entry }));
72
+ }
73
+ }
@@ -0,0 +1,37 @@
1
+ /**
2
+ * AES-256-GCM keystore for the Feishu App Secret.
3
+ *
4
+ * Key derivation: scrypt(machineId + home, salt) → 32-byte key.
5
+ * Salt is stored alongside ciphertext (it's the keystore record itself
6
+ * that's the secret — salt being public is fine, that's its job).
7
+ *
8
+ * File format (JSON, 0600 perms):
9
+ * { "v": 1, "iv": "...", "tag": "...", "salt": "...", "ciphertext": "..." }
10
+ * All blobs base64-encoded.
11
+ */
12
+ export interface KeystoreFile {
13
+ v: number;
14
+ iv: string;
15
+ tag: string;
16
+ salt: string;
17
+ ciphertext: string;
18
+ }
19
+ export declare class KeystoreError extends Error {
20
+ readonly cause?: unknown | undefined;
21
+ constructor(message: string, cause?: unknown | undefined);
22
+ }
23
+ export declare function encryptSecret(plaintext: string): KeystoreFile;
24
+ export declare function decryptSecret(record: KeystoreFile): string;
25
+ export declare function saveKeystoreFile(path: string, record: KeystoreFile): void;
26
+ export declare function loadKeystoreFile(path: string): KeystoreFile;
27
+ /**
28
+ * Encrypt-and-verify: encrypts the plaintext, then immediately decrypts it
29
+ * to confirm the keystore works on this machine. Throws on round-trip
30
+ * failure. Returns a stable check string callers may persist alongside
31
+ * config (e.g. `encryptCheck` field) so subsequent startups can sanity-check
32
+ * without round-tripping the full secret on every boot.
33
+ */
34
+ export declare function encryptWithSelfCheck(plaintext: string): {
35
+ record: KeystoreFile;
36
+ check: string;
37
+ };
@@ -0,0 +1,129 @@
1
+ /**
2
+ * AES-256-GCM keystore for the Feishu App Secret.
3
+ *
4
+ * Key derivation: scrypt(machineId + home, salt) → 32-byte key.
5
+ * Salt is stored alongside ciphertext (it's the keystore record itself
6
+ * that's the secret — salt being public is fine, that's its job).
7
+ *
8
+ * File format (JSON, 0600 perms):
9
+ * { "v": 1, "iv": "...", "tag": "...", "salt": "...", "ciphertext": "..." }
10
+ * All blobs base64-encoded.
11
+ */
12
+ import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from "node:crypto";
13
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
14
+ import { homedir, hostname } from "node:os";
15
+ const FILE_VERSION = 1;
16
+ const KEY_LENGTH = 32;
17
+ const IV_LENGTH = 12;
18
+ const TAG_LENGTH = 16;
19
+ const SALT_LENGTH = 16;
20
+ const SCRYPT_N = 1 << 14;
21
+ export class KeystoreError extends Error {
22
+ cause;
23
+ constructor(message, cause) {
24
+ super(message);
25
+ this.cause = cause;
26
+ this.name = "KeystoreError";
27
+ }
28
+ }
29
+ function deriveKey(salt) {
30
+ const material = `${hostname()}::${homedir()}`;
31
+ return scryptSync(material, salt, KEY_LENGTH, { N: SCRYPT_N });
32
+ }
33
+ export function encryptSecret(plaintext) {
34
+ const salt = randomBytes(SALT_LENGTH);
35
+ const iv = randomBytes(IV_LENGTH);
36
+ const key = deriveKey(salt);
37
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
38
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
39
+ const tag = cipher.getAuthTag();
40
+ return {
41
+ v: FILE_VERSION,
42
+ iv: iv.toString("base64"),
43
+ tag: tag.toString("base64"),
44
+ salt: salt.toString("base64"),
45
+ ciphertext: enc.toString("base64"),
46
+ };
47
+ }
48
+ export function decryptSecret(record) {
49
+ if (record.v !== FILE_VERSION) {
50
+ throw new KeystoreError(`Unsupported keystore version: ${record.v}`);
51
+ }
52
+ const salt = Buffer.from(record.salt, "base64");
53
+ const iv = Buffer.from(record.iv, "base64");
54
+ const tag = Buffer.from(record.tag, "base64");
55
+ const ciphertext = Buffer.from(record.ciphertext, "base64");
56
+ if (salt.length !== SALT_LENGTH)
57
+ throw new KeystoreError("Invalid salt length");
58
+ if (iv.length !== IV_LENGTH)
59
+ throw new KeystoreError("Invalid iv length");
60
+ if (tag.length !== TAG_LENGTH)
61
+ throw new KeystoreError("Invalid tag length");
62
+ const key = deriveKey(salt);
63
+ const decipher = createDecipheriv("aes-256-gcm", key, iv);
64
+ decipher.setAuthTag(tag);
65
+ try {
66
+ const dec = Buffer.concat([decipher.update(ciphertext), decipher.final()]);
67
+ return dec.toString("utf8");
68
+ }
69
+ catch (err) {
70
+ throw new KeystoreError("Failed to decrypt: machine identity changed, file corrupted, or wrong key", err);
71
+ }
72
+ }
73
+ export function saveKeystoreFile(path, record) {
74
+ writeFileSync(path, JSON.stringify(record), { encoding: "utf8", mode: 0o600 });
75
+ try {
76
+ chmodSync(path, 0o600);
77
+ }
78
+ catch {
79
+ // Some filesystems (Windows, certain Linux mounts) reject chmod; not fatal.
80
+ }
81
+ }
82
+ export function loadKeystoreFile(path) {
83
+ if (!existsSync(path)) {
84
+ throw new KeystoreError(`Keystore file not found: ${path}`);
85
+ }
86
+ let raw;
87
+ try {
88
+ raw = readFileSync(path, "utf8");
89
+ }
90
+ catch (err) {
91
+ throw new KeystoreError(`Failed to read keystore: ${path}`, err);
92
+ }
93
+ let parsed;
94
+ try {
95
+ parsed = JSON.parse(raw);
96
+ }
97
+ catch (err) {
98
+ throw new KeystoreError("Keystore file is not valid JSON", err);
99
+ }
100
+ if (!isKeystoreFile(parsed)) {
101
+ throw new KeystoreError("Keystore file has unexpected shape");
102
+ }
103
+ return parsed;
104
+ }
105
+ function isKeystoreFile(value) {
106
+ if (!value || typeof value !== "object")
107
+ return false;
108
+ const obj = value;
109
+ return (typeof obj.v === "number" &&
110
+ typeof obj.iv === "string" &&
111
+ typeof obj.tag === "string" &&
112
+ typeof obj.salt === "string" &&
113
+ typeof obj.ciphertext === "string");
114
+ }
115
+ /**
116
+ * Encrypt-and-verify: encrypts the plaintext, then immediately decrypts it
117
+ * to confirm the keystore works on this machine. Throws on round-trip
118
+ * failure. Returns a stable check string callers may persist alongside
119
+ * config (e.g. `encryptCheck` field) so subsequent startups can sanity-check
120
+ * without round-tripping the full secret on every boot.
121
+ */
122
+ export function encryptWithSelfCheck(plaintext) {
123
+ const record = encryptSecret(plaintext);
124
+ const back = decryptSecret(record);
125
+ if (back !== plaintext) {
126
+ throw new KeystoreError("Self-check failed: decrypt did not match original");
127
+ }
128
+ return { record, check: record.tag.slice(0, 8) };
129
+ }
@@ -0,0 +1,12 @@
1
+ /**
2
+ * `bubble serve --feishu` entry. Wires every layer of the host together.
3
+ */
4
+ export interface ServeFeishuOptions {
5
+ /** If true, force wizard re-run even if config exists. */
6
+ setup?: boolean;
7
+ /** If true, skip conflicting-process prompt and kill the old one. */
8
+ killOld?: boolean;
9
+ /** If true, exit immediately after a successful connect (CI / smoke test). */
10
+ dryRun?: boolean;
11
+ }
12
+ export declare function serveFeishu(opts?: ServeFeishuOptions): Promise<void>;