@openchambery/web 1.18.2-beta.1 → 1.18.2-beta.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.
Files changed (83) hide show
  1. package/dist/assets/{AssistantView-BmqAg8Cw.js → AssistantView-C-4WY4_Y.js} +1 -1
  2. package/dist/assets/{ChatContainer-Bd_gGZ0w.js → ChatContainer-Dg1fcQRH.js} +4 -4
  3. package/dist/assets/{DiagramView-IUHmsP8a.js → DiagramView-Cl5iwEHD.js} +1 -1
  4. package/dist/assets/{MarkdownRendererImpl-DCYzkDAx.js → MarkdownRendererImpl-N_HaSj0n.js} +1 -1
  5. package/dist/assets/{MobileDetailNavigation-C_nolgxy.js → MobileDetailNavigation-JFidUnW2.js} +1 -1
  6. package/dist/assets/{MobileShareBridge-DdVLVS8t.js → MobileShareBridge-Dtye6XbD.js} +1 -1
  7. package/dist/assets/{MobileSurface-BlBKmKB6.js → MobileSurface-uh9xGE4W.js} +1 -1
  8. package/dist/assets/{MultiRunWindow-CHcsJbf-.js → MultiRunWindow-CFVe1qvV.js} +1 -1
  9. package/dist/assets/{OnboardingScreen-CUsgwt4y.js → OnboardingScreen-DiPnIwza.js} +1 -1
  10. package/dist/assets/{OverlayScrollbar-BIik9dVI.js → OverlayScrollbar-DV6Bf5MB.js} +1 -1
  11. package/dist/assets/SettingsView-BJ0E268r.js +25 -0
  12. package/dist/assets/{SettingsWindow-Cmg8gTUn.js → SettingsWindow-yYmo-0Ic.js} +1 -1
  13. package/dist/assets/{TerminalView-K1b_ur_R.js → TerminalView-BNxPKANo.js} +1 -1
  14. package/dist/assets/{ToolOutputDialog-U6Y6cvHH.js → ToolOutputDialog-COiOtTRX.js} +1 -1
  15. package/dist/assets/{appThemeRegistry-Cj8Pu23O.js → appThemeRegistry-DT9JSHvx.js} +2 -2
  16. package/dist/assets/{clipboard-BW7nk_Cx.js → clipboard-CbqhDr1g.js} +1 -1
  17. package/dist/assets/{context-menu-ISe_NsrI.js → context-menu-CKua77Jz.js} +1 -1
  18. package/dist/assets/{dialog-DjYfj1Sn.js → dialog--cf4dygO.js} +1 -1
  19. package/dist/assets/{es-5Lhudh0n.js → es-DCoGZljP.js} +1 -1
  20. package/dist/assets/{fr-Q_UhFmKT.js → fr-dkEoZpPz.js} +2 -2
  21. package/dist/assets/{gitApi-ijVhBI00.js → gitApi-B7IetQyZ.js} +1 -1
  22. package/dist/assets/{insertionBoundaries-C6Fu8UI7.js → insertionBoundaries-CXhYvvQ1.js} +1 -1
  23. package/dist/assets/{ja-CzMPz7bT.js → ja-bQwSsvRr.js} +1 -1
  24. package/dist/assets/{ko-B-EslUsR.js → ko-Kq5C3hsv.js} +1 -1
  25. package/dist/assets/{main-BNVRs5Qb.js → main-CLlU-0_3.js} +2 -2
  26. package/dist/assets/{main-BSmiVacc.js → main-DStWT6gv.js} +4 -4
  27. package/dist/assets/{miniChat-CojzHV-6.js → miniChat-B4wfuAbi.js} +2 -2
  28. package/dist/assets/{mobile-BuH1uYzc.js → mobile-C6CUAJNV.js} +2 -2
  29. package/dist/assets/{multirun-BvW4wk6I.js → multirun-BvOVsUJY.js} +1 -1
  30. package/dist/assets/{pl-Dnea6Ppc.js → pl-CYpMAgVn.js} +1 -1
  31. package/dist/assets/{projectMeta-DihLz2v_.js → projectMeta-COUPYr2M.js} +1 -1
  32. package/dist/assets/{pt-BR-Ca6QTem7.js → pt-BR-MSPTLfTm.js} +1 -1
  33. package/dist/assets/{radio-BKXS9qG7.js → radio-ByL02rGC.js} +1 -1
  34. package/dist/assets/{renderElectronMiniChatApp-ts1ghAvW.js → renderElectronMiniChatApp-CmmF6m6P.js} +2 -2
  35. package/dist/assets/{renderMobileApp-DgXcVgpJ.js → renderMobileApp-BlkP7KQl.js} +5 -5
  36. package/dist/assets/{runtimeEndpointReset-Cb15HWwd.js → runtimeEndpointReset-CJfmeHr6.js} +2 -2
  37. package/dist/assets/{sortable-tabs-strip-MaeKZFdt.js → sortable-tabs-strip-BJcpkbMH.js} +1 -1
  38. package/dist/assets/src-CQN5yJYP.css +2 -0
  39. package/dist/assets/{store-wa1ersRf.js → store-OURVnLSN.js} +2 -2
  40. package/dist/assets/{uk-xGc9Pzge.js → uk-cSkRAYFY.js} +1 -1
  41. package/dist/assets/{useAppFontEffects-CkgySgdO.js → useAppFontEffects-BLm1WFVC.js} +4 -4
  42. package/dist/assets/{useDesktopWindowControlsLayout-CxI6wePK.js → useDesktopWindowControlsLayout-Bdc8u8ax.js} +1 -1
  43. package/dist/assets/{useEffectiveDirectory-4a-0hzcq.js → useEffectiveDirectory-By_ohSx-.js} +1 -1
  44. package/dist/assets/{useMobileNavigationStore-OlMvat05.js → useMobileNavigationStore-BQOQWNUY.js} +4 -4
  45. package/dist/assets/useSessionAutoCleanup-BmPmMhJN.js +1 -0
  46. package/dist/assets/{useWorkerHighlightedLines-6fZw2Zn0.js → useWorkerHighlightedLines-BdJWWBeW.js} +1 -1
  47. package/dist/assets/{zh-CN-sL4ktGB3.js → zh-CN-BKqeT_uz.js} +1 -1
  48. package/dist/assets/{zh-TW-Cfxps4gq.js → zh-TW-BaNjJ8Y4.js} +1 -1
  49. package/dist/index.html +2 -2
  50. package/dist/mini-chat.html +2 -2
  51. package/dist/mobile.html +2 -2
  52. package/package.json +1 -1
  53. package/server/index.d.ts +6 -0
  54. package/server/index.js +6 -0
  55. package/server/lib/config-sync/DOCUMENTATION.md +70 -0
  56. package/server/lib/config-sync/config-sync.test.js +197 -0
  57. package/server/lib/config-sync/constants.js +39 -0
  58. package/server/lib/config-sync/contract.js +145 -0
  59. package/server/lib/config-sync/credential-auth.js +39 -0
  60. package/server/lib/config-sync/engine.js +108 -0
  61. package/server/lib/config-sync/index.js +69 -0
  62. package/server/lib/config-sync/local-backup.js +171 -0
  63. package/server/lib/config-sync/plan.js +318 -0
  64. package/server/lib/config-sync/receiver.js +330 -0
  65. package/server/lib/config-sync/routes.js +141 -0
  66. package/server/lib/config-sync/routes.test.js +154 -0
  67. package/server/lib/config-sync/scripts.js +264 -0
  68. package/server/lib/config-sync/selections.js +112 -0
  69. package/server/lib/config-sync/tar.js +36 -0
  70. package/server/lib/config-sync/target-id.js +20 -0
  71. package/server/lib/git/DOCUMENTATION.md +2 -1
  72. package/server/lib/git/service.js +24 -5
  73. package/server/lib/git/service.test.js +51 -0
  74. package/server/lib/opencode/DOCUMENTATION.md +3 -0
  75. package/server/lib/opencode/bootstrap-runtime.js +2 -0
  76. package/server/lib/opencode/core-routes.js +1 -0
  77. package/server/lib/opencode/openchamber-routes.js +7 -0
  78. package/server/lib/opencode/settings-runtime.js +82 -51
  79. package/server/lib/opencode/settings-runtime.test.js +42 -0
  80. package/server/lib/relay/DOCUMENTATION.md +1 -1
  81. package/dist/assets/SettingsView-CCvrqxZK.js +0 -25
  82. package/dist/assets/src-oHrYIqHF.css +0 -2
  83. package/dist/assets/useSessionAutoCleanup-DjtbOIMV.js +0 -1
@@ -0,0 +1,264 @@
1
+ import path from 'node:path';
2
+
3
+ import {
4
+ OPENCODE_AGENTS_ROOT_PROBE_MARKER,
5
+ OPENCODE_AGENTS_SYNC_BACKUP_DIR,
6
+ OPENCODE_AUTH_FILE_PROBE_MARKER,
7
+ OPENCODE_AUTH_SYNC_BACKUP_DIR,
8
+ OPENCODE_CONFIG_SYNC_ALLOWLIST,
9
+ OPENCODE_CONFIG_SYNC_BACKUP_DIR,
10
+ OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS,
11
+ } from './constants.js';
12
+
13
+ const shellQuote = (value) => `'${String(value).replace(/'/g, `'\\''`)}'`;
14
+
15
+ /**
16
+ * Sanitize syncRunId for use as a single path segment under backup roots.
17
+ * @param {string} syncRunId
18
+ */
19
+ export const sanitizeSyncRunIdForPath = (syncRunId) => {
20
+ const trimmed = String(syncRunId || '').trim();
21
+ if (!trimmed) throw new Error('syncRunId is required for generational backup');
22
+ if (!/^[A-Za-z0-9._-]+$/.test(trimmed)) {
23
+ throw new Error('syncRunId contains unsupported characters for backup paths');
24
+ }
25
+ return trimmed;
26
+ };
27
+
28
+ /**
29
+ * Emit POSIX lines that create `<$backupVar>/<syncRunId>` as `$runVar` and prune older generations.
30
+ * Does not wipe the entire backup root — only replaces the current run dir and
31
+ * deletes generations beyond the retention limit (failed runs keep their scene).
32
+ *
33
+ * @param {{ backupVar: string, runVar: string, quotedRunId: string, generations: number }} args
34
+ * @returns {string[]}
35
+ */
36
+ const generationalBackupSetupLines = ({ backupVar, runVar, quotedRunId, generations }) => [
37
+ `mkdir -p "$${backupVar}"`,
38
+ `${runVar}="$${backupVar}"/${quotedRunId}`,
39
+ `rm -rf -- "$${runVar}"`,
40
+ `mkdir -p "$${runVar}"`,
41
+ `(cd "$${backupVar}" && ls -1t 2>/dev/null | awk 'NR>${generations} {print}' | while IFS= read -r old; do`,
42
+ ' [ -n "$old" ] || continue',
43
+ ` [ "$old" = ${quotedRunId} ] && continue`,
44
+ ` rm -rf -- "$${backupVar}/$old"`,
45
+ 'done) || true',
46
+ ];
47
+
48
+ /**
49
+ * Build the remote POSIX prepare script: generational backup, delete stale counterparts, print SYNC_READY.
50
+ * Backups land under `<backupRoot>/<syncRunId>/…`. Older generations beyond
51
+ * {@link OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS} are pruned; prepare no longer
52
+ * `rm -rf`s the entire backup root at start.
53
+ *
54
+ * @param {{ files: { path: string }[], directories: { path: string }[], deletes: string[], agentsRoot?: { fileCount: number, bytes: number } | null, authFile?: { bytes: number } | null }} plan
55
+ * @param {{ syncRunId: string, generations?: number }} options
56
+ * @returns {string}
57
+ */
58
+ export const buildRemoteSyncPrepareScript = (plan, options) => {
59
+ const syncRunId = sanitizeSyncRunIdForPath(options?.syncRunId);
60
+ const generations = Number.isFinite(options?.generations) && Number(options.generations) > 0
61
+ ? Math.trunc(Number(options.generations))
62
+ : OPENCODE_CONFIG_SYNC_BACKUP_GENERATIONS;
63
+ const backupDir = OPENCODE_CONFIG_SYNC_BACKUP_DIR;
64
+ const directoryDeleteNames = new Set();
65
+ for (const dirSpec of OPENCODE_CONFIG_SYNC_ALLOWLIST.directories) {
66
+ directoryDeleteNames.add(dirSpec.path);
67
+ if (typeof dirSpec.legacy === 'string' && dirSpec.legacy) {
68
+ directoryDeleteNames.add(dirSpec.legacy);
69
+ }
70
+ }
71
+
72
+ const quotedRunId = shellQuote(syncRunId);
73
+ const lines = [
74
+ 'set -e',
75
+ 'CFG="$HOME/.config/opencode"',
76
+ 'mkdir -p "$CFG"',
77
+ `BK="$CFG/${backupDir}"`,
78
+ ...generationalBackupSetupLines({
79
+ backupVar: 'BK',
80
+ runVar: 'RUN_BK',
81
+ quotedRunId,
82
+ generations,
83
+ }),
84
+ ];
85
+
86
+ const backupPaths = [
87
+ ...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
88
+ ...(Array.isArray(plan?.directories) ? plan.directories.map((entry) => entry.path) : []),
89
+ ];
90
+
91
+ for (const rel of backupPaths) {
92
+ const quoted = shellQuote(rel);
93
+ const dirname = path.posix.dirname(rel);
94
+ const quotedDir = shellQuote(dirname === '.' ? '' : dirname);
95
+ lines.push(`if [ -e "$CFG"/${quoted} ]; then`);
96
+ if (dirname === '.' || dirname === '') {
97
+ lines.push(' mkdir -p "$RUN_BK"');
98
+ } else {
99
+ lines.push(` mkdir -p "$RUN_BK"/${quotedDir}`);
100
+ }
101
+ lines.push(` cp -a "$CFG"/${quoted} "$RUN_BK"/${quoted}`);
102
+ lines.push('fi');
103
+ }
104
+
105
+ for (const rel of Array.isArray(plan?.deletes) ? plan.deletes : []) {
106
+ const quoted = shellQuote(rel);
107
+ if (directoryDeleteNames.has(rel)) {
108
+ lines.push(`rm -rf -- "$CFG"/${quoted}`);
109
+ } else {
110
+ lines.push(`rm -f -- "$CFG"/${quoted}`);
111
+ }
112
+ }
113
+
114
+ if (plan?.agentsRoot) {
115
+ lines.push(`AGENTS_BK="$HOME/${OPENCODE_AGENTS_SYNC_BACKUP_DIR}"`);
116
+ lines.push(...generationalBackupSetupLines({
117
+ backupVar: 'AGENTS_BK',
118
+ runVar: 'AGENTS_RUN_BK',
119
+ quotedRunId,
120
+ generations,
121
+ }));
122
+ lines.push('if [ -e "$HOME/.agents" ]; then');
123
+ lines.push(' cp -a "$HOME/.agents" "$AGENTS_RUN_BK/agents"');
124
+ lines.push('fi');
125
+ lines.push('rm -rf -- "$HOME/.agents"');
126
+ }
127
+
128
+ if (plan?.authFile) {
129
+ lines.push(`AUTH_BK="$HOME/${OPENCODE_AUTH_SYNC_BACKUP_DIR}"`);
130
+ lines.push(...generationalBackupSetupLines({
131
+ backupVar: 'AUTH_BK',
132
+ runVar: 'AUTH_RUN_BK',
133
+ quotedRunId,
134
+ generations,
135
+ }));
136
+ lines.push('if [ -e "$HOME/.local/share/opencode/auth.json" ]; then');
137
+ lines.push(' cp -a "$HOME/.local/share/opencode/auth.json" "$AUTH_RUN_BK/auth.json"');
138
+ lines.push('fi');
139
+ }
140
+
141
+ lines.push("printf 'SYNC_READY\\n'");
142
+ return lines.join('\n');
143
+ };
144
+
145
+ /**
146
+ * Build the remote existence probe script for the sync preview.
147
+ * @param {string[]} uniqueProbePaths
148
+ * @returns {string}
149
+ */
150
+ export const buildRemoteSyncProbeScript = (uniqueProbePaths) => {
151
+ const lines = (Array.isArray(uniqueProbePaths) ? uniqueProbePaths : []).map((rel) => {
152
+ const quoted = shellQuote(rel);
153
+ return `[ -e "$HOME/.config/opencode"/${quoted} ] && printf '%s\\n' ${quoted}`;
154
+ });
155
+ lines.push(
156
+ `[ -e "$HOME/.agents" ] && printf '%s\\n' ${shellQuote(OPENCODE_AGENTS_ROOT_PROBE_MARKER)}`,
157
+ );
158
+ lines.push(
159
+ `[ -e "$HOME/.local/share/opencode/auth.json" ] && printf '%s\\n' ${shellQuote(OPENCODE_AUTH_FILE_PROBE_MARKER)}`,
160
+ );
161
+ lines.push('exit 0');
162
+ return lines.join('\n');
163
+ };
164
+
165
+ /**
166
+ * Finalize script: confirm generational backup dir exists for this syncRunId and print SYNC_DONE.
167
+ * @param {{ syncRunId: string }} options
168
+ */
169
+ export const buildRemoteSyncFinalizeScript = (options) => {
170
+ const syncRunId = sanitizeSyncRunIdForPath(options?.syncRunId);
171
+ const quotedRunId = shellQuote(syncRunId);
172
+ return [
173
+ 'set -e',
174
+ `BK="$HOME/.config/opencode/${OPENCODE_CONFIG_SYNC_BACKUP_DIR}"/${quotedRunId}`,
175
+ 'if [ ! -d "$BK" ]; then',
176
+ " printf 'SYNC_FINALIZE_MISSING_BACKUP\\n' >&2",
177
+ ' exit 1',
178
+ 'fi',
179
+ "printf 'SYNC_DONE\\n'",
180
+ ].join('\n');
181
+ };
182
+
183
+ /**
184
+ * Build a remote inventory script that prints a JSON object describing which
185
+ * allowlist paths exist on the remote (for pull planning). Uses only POSIX sh.
186
+ * Output is a single JSON line prefixed by SYNC_INVENTORY=.
187
+ * @returns {string}
188
+ */
189
+ export const buildRemoteSyncInventoryScript = () => {
190
+ const filePaths = [
191
+ ...OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups.flat(),
192
+ ...OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles,
193
+ ];
194
+ const dirPaths = OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.map((entry) => entry.path);
195
+ const lines = [
196
+ 'set -e',
197
+ 'CFG="$HOME/.config/opencode"',
198
+ 'FILES=""',
199
+ 'DIRS=""',
200
+ ];
201
+ for (const rel of filePaths) {
202
+ const quoted = shellQuote(rel);
203
+ lines.push(`if [ -f "$CFG"/${quoted} ]; then`);
204
+ lines.push(` BYTES="$(wc -c < "$CFG"/${quoted} | tr -d ' ')"`);
205
+ lines.push(` FILES="$FILES{\\"path\\":${JSON.stringify(rel)},\\"bytes\\":$BYTES},"`);
206
+ lines.push('fi');
207
+ }
208
+ for (const rel of dirPaths) {
209
+ const quoted = shellQuote(rel);
210
+ lines.push(`if [ -d "$CFG"/${quoted} ]; then`);
211
+ lines.push(` COUNT="$(find "$CFG"/${quoted} -type f ! -path '*/node_modules/*' ! -name '*.backup' 2>/dev/null | wc -l | tr -d ' ')"`);
212
+ lines.push(` BYTES="$(find "$CFG"/${quoted} -type f ! -path '*/node_modules/*' ! -name '*.backup' -exec wc -c {} + 2>/dev/null | awk 'END{print $1+0}')"`);
213
+ lines.push(` DIRS="$DIRS{\\"path\\":${JSON.stringify(rel)},\\"fileCount\\":$COUNT,\\"bytes\\":$BYTES},"`);
214
+ lines.push('fi');
215
+ }
216
+ lines.push('AGENTS="null"');
217
+ lines.push('if [ -d "$HOME/.agents" ]; then');
218
+ lines.push(" ACOUNT=\"$(find \"$HOME/.agents\" -type f ! -path '*/node_modules/*' ! -name '*.backup' 2>/dev/null | wc -l | tr -d ' ')\"");
219
+ lines.push(" ABYTES=\"$(find \"$HOME/.agents\" -type f ! -path '*/node_modules/*' ! -name '*.backup' -exec wc -c {} + 2>/dev/null | awk 'END{print $1+0}')\"");
220
+ lines.push(' AGENTS="{\\"fileCount\\":$ACOUNT,\\"bytes\\":$ABYTES}"');
221
+ lines.push('fi');
222
+ lines.push('AUTH="null"');
223
+ lines.push('if [ -f "$HOME/.local/share/opencode/auth.json" ]; then');
224
+ lines.push(' AUBYTES="$(wc -c < "$HOME/.local/share/opencode/auth.json" | tr -d \' \')"');
225
+ lines.push(' AUTH="{\\"bytes\\":$AUBYTES}"');
226
+ lines.push('fi');
227
+ lines.push('FILES="${FILES%,}"');
228
+ lines.push('DIRS="${DIRS%,}"');
229
+ lines.push('printf \'SYNC_INVENTORY={"files":[%s],"directories":[%s],"agentsRoot":%s,"authFile":%s}\\n\' "$FILES" "$DIRS" "$AGENTS" "$AUTH"');
230
+ lines.push('exit 0');
231
+ return lines.join('\n');
232
+ };
233
+
234
+ /**
235
+ * Stream a remote tar.gz of selected config paths to stdout.
236
+ * @param {{ files?: { path: string }[], directories?: { path: string }[] }} plan
237
+ */
238
+ export const buildRemoteConfigTarScript = (plan) => {
239
+ const entries = [
240
+ ...(Array.isArray(plan?.files) ? plan.files.map((entry) => entry.path) : []),
241
+ ...(Array.isArray(plan?.directories) ? plan.directories.map((entry) => entry.path) : []),
242
+ ];
243
+ if (entries.length === 0) {
244
+ return "printf '' ; exit 0";
245
+ }
246
+ const quotedEntries = entries.map((rel) => shellQuote(rel)).join(' ');
247
+ return [
248
+ 'set -e',
249
+ 'CFG="$HOME/.config/opencode"',
250
+ `cd "$CFG" && tar -h -czf - ${quotedEntries}`,
251
+ ].join('\n');
252
+ };
253
+
254
+ export const buildRemoteAgentsTarScript = () => [
255
+ 'set -e',
256
+ 'cd "$HOME" && tar -h -czf - .agents',
257
+ ].join('\n');
258
+
259
+ export const buildRemoteAuthTarScript = () => [
260
+ 'set -e',
261
+ 'cd "$HOME/.local/share/opencode" && tar -h -czf - auth.json',
262
+ ].join('\n');
263
+
264
+ export { shellQuote };
@@ -0,0 +1,112 @@
1
+ import { OPENCODE_CONFIG_SYNC_ALLOWLIST } from './constants.js';
2
+
3
+ /**
4
+ * @typedef {{
5
+ * fileGroups: boolean[],
6
+ * singleFiles: boolean[],
7
+ * directories: boolean[],
8
+ * agentsRoot: boolean,
9
+ * authFile: boolean,
10
+ * }} SyncSelections
11
+ */
12
+
13
+ /**
14
+ * Default: everything selected except authFile (credentials stay opt-in).
15
+ * @param {{ includeAuthFile?: boolean }} [options]
16
+ * @returns {SyncSelections}
17
+ */
18
+ export const buildDefaultSyncSelections = (options = {}) => ({
19
+ fileGroups: OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups.map(() => true),
20
+ singleFiles: OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles.map(() => true),
21
+ directories: OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.map(() => true),
22
+ agentsRoot: true,
23
+ authFile: options.includeAuthFile === true,
24
+ });
25
+
26
+ /**
27
+ * Normalize a partial selections object against the allowlist shape.
28
+ * @param {Partial<SyncSelections> | null | undefined} raw
29
+ * @param {{ includeAuthFile?: boolean }} [options]
30
+ * @returns {SyncSelections}
31
+ */
32
+ export const normalizeSyncSelections = (raw, options = {}) => {
33
+ const defaults = buildDefaultSyncSelections(options);
34
+ if (!raw || typeof raw !== 'object') return defaults;
35
+
36
+ const fileGroups = OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups.map((_, index) => (
37
+ Array.isArray(raw.fileGroups) ? raw.fileGroups[index] !== false : defaults.fileGroups[index]
38
+ ));
39
+ const singleFiles = OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles.map((_, index) => (
40
+ Array.isArray(raw.singleFiles) ? raw.singleFiles[index] !== false : defaults.singleFiles[index]
41
+ ));
42
+ const directories = OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.map((_, index) => (
43
+ Array.isArray(raw.directories) ? raw.directories[index] !== false : defaults.directories[index]
44
+ ));
45
+
46
+ return {
47
+ fileGroups,
48
+ singleFiles,
49
+ directories,
50
+ agentsRoot: raw.agentsRoot !== false,
51
+ authFile: raw.authFile === true,
52
+ };
53
+ };
54
+
55
+ /**
56
+ * Filter a full plan down to the selected allowlist groups.
57
+ * Mutual-exclusion within a fileGroup is preserved (plan already picked a winner).
58
+ * @param {object} plan
59
+ * @param {SyncSelections} selections
60
+ */
61
+ export const filterPlanBySelections = (plan, selections) => {
62
+ const selected = normalizeSyncSelections(selections, { includeAuthFile: selections?.authFile === true });
63
+ const selectedFileNames = new Set();
64
+ const selectedDeleteNames = new Set();
65
+
66
+ OPENCODE_CONFIG_SYNC_ALLOWLIST.fileGroups.forEach((group, index) => {
67
+ if (!selected.fileGroups[index]) return;
68
+ for (const name of group) {
69
+ selectedFileNames.add(name);
70
+ selectedDeleteNames.add(name);
71
+ }
72
+ });
73
+ OPENCODE_CONFIG_SYNC_ALLOWLIST.singleFiles.forEach((name, index) => {
74
+ if (!selected.singleFiles[index]) return;
75
+ selectedFileNames.add(name);
76
+ });
77
+
78
+ const selectedDirNames = new Set();
79
+ const selectedDirLegacy = new Set();
80
+ OPENCODE_CONFIG_SYNC_ALLOWLIST.directories.forEach((dirSpec, index) => {
81
+ if (!selected.directories[index]) return;
82
+ selectedDirNames.add(dirSpec.path);
83
+ selectedDeleteNames.add(dirSpec.path);
84
+ if (typeof dirSpec.legacy === 'string' && dirSpec.legacy) {
85
+ selectedDirLegacy.add(dirSpec.legacy);
86
+ selectedDeleteNames.add(dirSpec.legacy);
87
+ }
88
+ });
89
+
90
+ const files = (Array.isArray(plan?.files) ? plan.files : []).filter((entry) => selectedFileNames.has(entry.path));
91
+ const directories = (Array.isArray(plan?.directories) ? plan.directories : []).filter((entry) => selectedDirNames.has(entry.path));
92
+ const deletes = (Array.isArray(plan?.deletes) ? plan.deletes : []).filter((name) => selectedDeleteNames.has(name) || selectedDirLegacy.has(name));
93
+ const agentsRoot = selected.agentsRoot ? (plan?.agentsRoot ?? null) : null;
94
+ const authFile = selected.authFile ? (plan?.authFile ?? null) : null;
95
+
96
+ let totalBytes = 0;
97
+ for (const entry of files) totalBytes += Number(entry.bytes) || 0;
98
+ for (const entry of directories) totalBytes += Number(entry.bytes) || 0;
99
+ if (agentsRoot) totalBytes += Number(agentsRoot.bytes) || 0;
100
+ if (authFile) totalBytes += Number(authFile.bytes) || 0;
101
+
102
+ return {
103
+ ...plan,
104
+ files,
105
+ directories,
106
+ deletes,
107
+ agentsRoot,
108
+ authFile,
109
+ totalBytes,
110
+ selections: selected,
111
+ };
112
+ };
@@ -0,0 +1,36 @@
1
+ import { spawn } from 'node:child_process';
2
+
3
+ /**
4
+ * Collect stdout from a local `tar …` into a Buffer.
5
+ *
6
+ * Current callers buffer the entire archive in memory (SSH stdin putTar).
7
+ * The TargetExecutor.putTar contract accepts Buffer | AsyncIterable | Readable
8
+ * so a later relay transport can stream without changing the executor signature.
9
+ *
10
+ * @param {string[]} tarArgs
11
+ * @param {{ windowsHide?: boolean }} [spawnOptions]
12
+ * @returns {Promise<Buffer>}
13
+ */
14
+ export const collectLocalTarBuffer = (tarArgs, spawnOptions = {}) => new Promise((resolve, reject) => {
15
+ const child = spawn('tar', tarArgs, {
16
+ stdio: ['ignore', 'pipe', 'pipe'],
17
+ ...(spawnOptions.windowsHide ? { windowsHide: true } : {}),
18
+ });
19
+ /** @type {Buffer[]} */
20
+ const chunks = [];
21
+ let stderr = '';
22
+ child.stdout?.on('data', (chunk) => {
23
+ chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk));
24
+ });
25
+ child.stderr?.on('data', (chunk) => {
26
+ stderr += chunk.toString();
27
+ });
28
+ child.on('error', reject);
29
+ child.on('close', (code) => {
30
+ if (code !== 0) {
31
+ reject(new Error((stderr || 'Local tar failed').trim()));
32
+ return;
33
+ }
34
+ resolve(Buffer.concat(chunks));
35
+ });
36
+ });
@@ -0,0 +1,20 @@
1
+ /** @param {string} instanceId */
2
+ export const syncTargetIdForSshInstance = (instanceId) => {
3
+ const id = String(instanceId || '').trim();
4
+ if (!id) throw new Error('SSH instance id is required');
5
+ return `ssh:${id}`;
6
+ };
7
+
8
+ /** @param {string} hostId */
9
+ export const syncTargetIdForDirectHost = (hostId) => {
10
+ const id = String(hostId || '').trim();
11
+ if (!id) throw new Error('Desktop host id is required');
12
+ return `host:${id}`;
13
+ };
14
+
15
+ /** @param {string} serverId relay host signing-key fingerprint */
16
+ export const syncTargetIdForRelayServer = (serverId) => {
17
+ const id = String(serverId || '').trim();
18
+ if (!id) throw new Error('Relay serverId is required');
19
+ return `relay:${id}`;
20
+ };
@@ -111,7 +111,8 @@ The following functions are internal helpers used by exported functions:
111
111
  - `upstreamComparison`: Optional comparison against `upstream/<current-branch>`, with `{ remote, branch, ahead, behind }`.
112
112
  - `files`: Array of file objects with `path`, `index`, `working_dir` status codes.
113
113
  - `isClean`: Boolean indicating if working tree is clean.
114
- - `diffStats`: Object mapping file paths to `{ insertions, deletions }`.
114
+ - `oversized`: Boolean. `true` when the change set exceeds the server-owned `GIT_STATUS_DIFF_STATS_MAX_FILES` threshold; `diffStats` are omitted in that case (same omission shape as light mode). Clients must derive deferred / load-on-demand rendering from this flag rather than from their own threshold, so the server can tune the limit without client changes. Light-mode polls do not set this flag.
115
+ - `diffStats`: Object mapping file paths to `{ insertions, deletions }`. Omitted by light mode and by oversized change sets.
115
116
  - `mergeInProgress`: Object with `{ head, message }` if merge in progress.
116
117
  - `rebaseInProgress`: Object with `{ headName, onto }` if rebase in progress.
117
118
 
@@ -1964,6 +1964,15 @@ export async function setLocalIdentity(directory, profile) {
1964
1964
  }
1965
1965
  }
1966
1966
 
1967
+ // Change sets larger than this are reported as `oversized` in the getStatus
1968
+ // response and omit diffStats (the same omission shape as light mode). Two
1969
+ // full-tree `git diff --numstat` passes and per-new-file line counting over an
1970
+ // oversized working tree block the server event loop for seconds; regular
1971
+ // repositories never reach this threshold. Clients must derive deferred /
1972
+ // load-on-demand behavior from the `oversized` flag, never from their own copy
1973
+ // of this threshold.
1974
+ const GIT_STATUS_DIFF_STATS_MAX_FILES = 2000;
1975
+
1967
1976
  export async function getStatus(directory, options = {}) {
1968
1977
  const lightMode = options.mode === 'light';
1969
1978
 
@@ -1973,8 +1982,17 @@ export async function getStatus(directory, options = {}) {
1973
1982
  // Use -uall to show all untracked files individually, not just directories
1974
1983
  const status = await git.status(['-uall']);
1975
1984
 
1985
+ // Oversized change sets: skip numstat + new-file stats the same way light
1986
+ // mode does. Two full-tree `git diff --numstat` passes over a huge working
1987
+ // tree take seconds and block the server event loop; regular repositories
1988
+ // stay below the threshold and keep full diffStats. The `oversized` flag in
1989
+ // the response tells clients to defer list rendering; light mode is a
1990
+ // client-requested lightweight poll and does not set the flag.
1991
+ const oversized = (status.files?.length ?? 0) > GIT_STATUS_DIFF_STATS_MAX_FILES;
1992
+ const skipDiffStats = lightMode || oversized;
1993
+
1976
1994
  // Light mode: skip numstat + new-file line counting for faster response
1977
- const [stagedStatsRaw, workingStatsRaw] = lightMode
1995
+ const [stagedStatsRaw, workingStatsRaw] = skipDiffStats
1978
1996
  ? ['', '']
1979
1997
  : await Promise.all([
1980
1998
  git.raw(['diff', '--cached', '--numstat']).catch(() => ''),
@@ -2019,7 +2037,7 @@ export async function getStatus(directory, options = {}) {
2019
2037
  const MAX_NEW_FILE_STAT_SIZE = 1024 * 1024;
2020
2038
  const newFileStats = [];
2021
2039
 
2022
- if (!lightMode) {
2040
+ if (!skipDiffStats) {
2023
2041
  for (const file of status.files) {
2024
2042
  if (newFileStats.length >= MAX_NEW_FILE_STATS) {
2025
2043
  break;
@@ -2126,7 +2144,7 @@ export async function getStatus(directory, options = {}) {
2126
2144
  // When no upstream is configured (common for new worktree branches), Git doesn't report ahead/behind.
2127
2145
  // We still want to show the number of unpublished commits to the user.
2128
2146
  // Light mode skips this — the basic ahead/behind from git status is sufficient for polling.
2129
- if (!lightMode && !tracking && status.current) {
2147
+ if (!skipDiffStats && !tracking && status.current) {
2130
2148
  const baseRef = await selectBaseRefForUnpublished();
2131
2149
  if (baseRef) {
2132
2150
  const countRaw = await git
@@ -2142,7 +2160,7 @@ export async function getStatus(directory, options = {}) {
2142
2160
  }
2143
2161
 
2144
2162
  if (
2145
- !lightMode
2163
+ !skipDiffStats
2146
2164
  && status.current
2147
2165
  && (!tracking || !tracking.startsWith('upstream/'))
2148
2166
  && await hasRemote(git, directoryPath, 'upstream')
@@ -2217,7 +2235,8 @@ export async function getStatus(directory, options = {}) {
2217
2235
  working_dir: f.working_dir,
2218
2236
  })),
2219
2237
  isClean: status.isClean(),
2220
- diffStats: lightMode ? undefined : diffStats,
2238
+ oversized,
2239
+ diffStats: skipDiffStats ? undefined : diffStats,
2221
2240
  mergeInProgress,
2222
2241
  rebaseInProgress,
2223
2242
  };
@@ -282,6 +282,57 @@ describe('getStatus', () => {
282
282
 
283
283
  await expect(getStatus(repo)).resolves.toMatchObject({ current: 'main' });
284
284
  });
285
+
286
+ it('returns diffStats for regular-sized change sets', async () => {
287
+ if (!canRunGit()) return;
288
+
289
+ const repo = createTempDir();
290
+ runGit(repo, ['init', '-b', 'main']);
291
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
292
+ runGit(repo, ['config', 'user.name', 'Test User']);
293
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
294
+ runGit(repo, ['add', 'README.md']);
295
+ runGit(repo, ['commit', '-m', 'Initial commit']);
296
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nMore lines\n');
297
+
298
+ const status = await getStatus(repo);
299
+ expect(status.oversized).toBe(false);
300
+ expect(status.diffStats).toBeDefined();
301
+ expect(status.diffStats['README.md']).toEqual({ insertions: 1, deletions: 0 });
302
+ });
303
+
304
+ it('marks oversized change sets and omits diffStats without changing the rest of the contract', async () => {
305
+ if (!canRunGit()) return;
306
+
307
+ const repo = createTempDir();
308
+ runGit(repo, ['init', '-b', 'main']);
309
+ runGit(repo, ['config', 'user.email', 'test@example.com']);
310
+ runGit(repo, ['config', 'user.name', 'Test User']);
311
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\n');
312
+ runGit(repo, ['add', 'README.md']);
313
+ runGit(repo, ['commit', '-m', 'Initial commit']);
314
+
315
+ // Create one modified tracked file plus enough untracked files to cross
316
+ // the oversized threshold (untracked files are part of status.files).
317
+ fs.writeFileSync(path.join(repo, 'README.md'), '# Test\nChanged\n');
318
+ const oversizeCount = 2001;
319
+ fs.mkdirSync(path.join(repo, 'generated'), { recursive: true });
320
+ for (let i = 0; i < oversizeCount; i += 1) {
321
+ fs.writeFileSync(path.join(repo, 'generated', `file-${i}.txt`), `content ${i}\n`);
322
+ }
323
+
324
+ const status = await getStatus(repo);
325
+ expect(status.files.length).toBeGreaterThan(2000);
326
+ // The server flags the oversized state so clients defer list rendering.
327
+ expect(status.oversized).toBe(true);
328
+ // Same omission shape as light mode: the key is absent, not an empty object.
329
+ expect(status.diffStats).toBeUndefined();
330
+ // Everything else in the contract stays intact.
331
+ expect(status.current).toBe('main');
332
+ expect(status.isClean).toBe(false);
333
+ expect(status.files.some((file) => file.path === 'README.md')).toBe(true);
334
+ expect(status.files.some((file) => file.path.startsWith('generated/'))).toBe(true);
335
+ });
285
336
  });
286
337
 
287
338
  // ---------------------------------------------------------------------------
@@ -184,13 +184,16 @@ This module provides OpenCode server integration utilities for the web server ru
184
184
 
185
185
  ## Public exports (settings-runtime.js)
186
186
  - `createSettingsRuntime(dependencies)`: creates settings lifecycle runtime for read/migrate/persist concerns.
187
+ - Optional dependency `runExclusivePersist` / `getRunExclusivePersist`, plus returned `setRunExclusivePersist(fn)`, let an in-process host (Electron) replace the default `persistSettingsLock` with a shared exclusive runner so desktop main/ssh writers and web settings persistence serialize on one chain.
187
188
  - Returned API:
188
189
  - `readSettingsFromDisk()`
189
190
  - `readSettingsFromDiskMigrated()`
190
191
  - `writeSettingsToDisk(settings)`
191
192
  - `persistSettings(changes)`
193
+ - `setRunExclusivePersist(fn)`
192
194
  - Persistent permission auto-accept policy is stored under `permissionAutoAccept`; execution ownership lives in `lib/permission-auto-accept/`.
193
195
  - One-shot compact-chat defaults migration: when disk marker `compactChatDefaultsMigrationVersion` is missing, rewrite legacy/absent `chatRenderMode`/`activityRenderMode`/`showTurnChangedFiles` to `sorted`/`collapsed`/`true` and persist marker `1` (marker stays on disk; response allowlist still hides it). Marker already `1` preserves user values; `persistSettings` runs the same migration before the first write.
196
+ - `startWebUiServer({ settingsPersistLock })` late-binds that shared runner after module load.
194
197
 
195
198
  ## Public exports (settings-helpers.js)
196
199
  - `createSettingsHelpers(dependencies)`: creates settings helper runtime for settings request/response shaping.
@@ -30,6 +30,7 @@ export const createBootstrapRuntime = (dependencies) => {
30
30
  getServerId,
31
31
  getServerLabel,
32
32
  readSettingsFromDiskMigrated,
33
+ persistSettings,
33
34
  normalizeTunnelSessionTtlMs,
34
35
  authorizeManagedOpenCodeBridgeRequest,
35
36
  sayTTSCapability,
@@ -147,6 +148,7 @@ export const createBootstrapRuntime = (dependencies) => {
147
148
  __dirname,
148
149
  openchamberDataDir,
149
150
  readSettingsFromDiskMigrated,
151
+ persistSettings,
150
152
  fetchFreeZenModels,
151
153
  getCachedZenModels,
152
154
  sessionIndexService,
@@ -1127,6 +1127,7 @@ export const registerCommonRequestMiddleware = (app, dependencies) => {
1127
1127
  } else if (
1128
1128
  (req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/message-queue\/attachments\/uploads\/[^/]+\/?$/))
1129
1129
  || (req.method === 'PUT' && req.path.match(/^\/api\/fs\/prompt-attachments\/[^/]+\/?$/))
1130
+ || (req.method === 'PUT' && req.path.match(/^\/api\/openchamber\/config-sync\/put\/[^/]+\/?$/))
1130
1131
  ) {
1131
1132
  // Binary attachment uploads stream as the request body. Parsing JSON here
1132
1133
  // would buffer the entire payload and block the relay tunnel.
@@ -1,6 +1,7 @@
1
1
  import { registerSessionIndexRoutes } from '../session-index/routes.js';
2
2
  import { registerTranscriptCacheRoutes } from '../transcript-cache/routes.js';
3
3
  import { registerDesktopHostRoutes } from '../desktop-hosts/routes.js';
4
+ import { registerConfigSyncRoutes } from '../config-sync/routes.js';
4
5
 
5
6
  export const registerOpenChamberRoutes = (app, dependencies) => {
6
7
  const {
@@ -11,6 +12,7 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
11
12
  __dirname,
12
13
  openchamberDataDir,
13
14
  readSettingsFromDiskMigrated,
15
+ persistSettings,
14
16
  fetchFreeZenModels,
15
17
  getCachedZenModels,
16
18
  sessionIndexService,
@@ -31,6 +33,11 @@ export const registerOpenChamberRoutes = (app, dependencies) => {
31
33
  getPairingSession,
32
34
  express,
33
35
  });
36
+ registerConfigSyncRoutes(app, {
37
+ readSettingsFromDiskMigrated,
38
+ persistSettings,
39
+ express,
40
+ });
34
41
 
35
42
  app.get('/api/openchamber/update-check', async (req, res) => {
36
43
  try {