@kendoo.agentdesk/agentdesk 0.20.2 → 0.20.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.
package/CHANGELOG.md CHANGED
@@ -13,6 +13,13 @@ Internal refactors, infrastructure changes, and architectural notes are not list
13
13
  ### Added
14
14
  - `[UI]` Private-session sharing flow. When someone visits a session URL they don't own, they now see a friendly "Shh… this one's private" page with a one-click "Request access" button instead of a blank error. The session owner sees pending requests in a new header inbox and can grant viewer access for just that session or the whole project. Viewer-granted sessions show up in the teammate's sidebar tagged as a viewer.
15
15
 
16
+ ## [0.20.3] — 2026-04-19
17
+
18
+ ### Fixed
19
+ - `[CLI]` Critical: `loadConfig` no longer lets a server row with `null` or missing fields override a fully-configured local `.agentdesk.json`. The merge now treats `null`/`undefined` from the server as "no value known" — only explicit non-null server values win. Previously a partially-synced project row on agentdesk.live would silently strip local tracker/repo/badge fields during the merge, and the cached-back write then persisted the stripped state to disk. On the next `init` run, the config looked empty even though the user had typed everything in.
20
+ - `[CLI]` Existing-mode save in `init` preserves the full local `.agentdesk.json` — it only ensures `projectKey` is set, never replaces other fields. The previous behavior wrote `{ "projectKey": "..." }` as the entire file, which destroyed any data not also on the server.
21
+ - `[CLI]` Auto-heal sync: when `loadConfig` sees a server row missing fields the local file has, it now pushes the merged config back to the server (fire-and-forget) so both sides converge. Projects that were set up before server-settings-push landed will heal themselves on next init.
22
+
16
23
  ## [0.20.2] — 2026-04-19
17
24
 
18
25
  ### Changed
package/cli/config.mjs CHANGED
@@ -87,20 +87,32 @@ export async function loadConfig(dir, opts = {}) {
87
87
  }
88
88
  }
89
89
 
90
- // Merge: DEFAULTS ← local ← server (UI is the single source of truth)
90
+ // Merge: DEFAULTS ← local ← server, where the "server overrides local"
91
+ // rule only kicks in when the server actually has a value. Null /
92
+ // undefined on the server means "the server doesn't know about this
93
+ // field yet" — local wins. This is what keeps a partially-configured
94
+ // server row from silently wiping a fully-configured local file on
95
+ // next read.
91
96
  let config = { ...DEFAULTS };
92
97
  if (localConfig) config = deepMerge(config, localConfig);
93
- if (serverConfig) config = deepMerge(config, serverConfig);
94
-
95
- // First-time sync: if local exists but server is empty, push local up.
96
- if (localConfig && !serverConfig && apiKey && serverUrl && projectName) {
97
- pushConfig(apiKey, serverUrl, projectName, localConfig).catch(() => {});
98
+ if (serverConfig) config = mergeNonNull(config, serverConfig);
99
+
100
+ // Auto-heal sync: if local has fields the server is missing (either
101
+ // no row at all or a partial row), push the merged config up so the
102
+ // server catches up. Fire-and-forget — we already have the right
103
+ // answer in `config` for the current caller.
104
+ const shouldHeal = localConfig && apiKey && serverUrl && projectName && (
105
+ !serverConfig || hasFieldsServerLacks(localConfig, serverConfig)
106
+ );
107
+ if (shouldHeal) {
108
+ pushConfig(apiKey, serverUrl, projectName, stripCredentials(config)).catch(() => {});
98
109
  }
99
110
 
100
- // Cache the merged, credential-free config back to disk so the local file
101
- // stays a readable view of the authoritative state. Only when we actually
102
- // fetched from the server — offline runs must not silently mutate the
103
- // user's local file.
111
+ // Cache the merged, credential-free config back to disk so the local
112
+ // file stays a readable view of the authoritative state. Only when we
113
+ // actually fetched from the server — offline runs must not silently
114
+ // mutate the user's local file. With mergeNonNull above, we're
115
+ // guaranteed this write never strips existing local fields.
104
116
  if (serverConfig) {
105
117
  writeLocalConfig(configPath, config);
106
118
  }
@@ -108,6 +120,23 @@ export async function loadConfig(dir, opts = {}) {
108
120
  return config;
109
121
  }
110
122
 
123
+ // True when `local` has any non-null leaf that's null/missing on `server`.
124
+ // Walked recursively for nested blocks (linear, jira, github). Used to
125
+ // decide whether it's worth pushing a heal sync.
126
+ function hasFieldsServerLacks(local, server) {
127
+ const isObj = v => v && typeof v === "object" && !Array.isArray(v);
128
+ for (const [key, v] of Object.entries(local || {})) {
129
+ if (v === null || v === undefined) continue;
130
+ const s = server?.[key];
131
+ if (isObj(v)) {
132
+ if (!isObj(s) || hasFieldsServerLacks(v, s)) return true;
133
+ } else {
134
+ if (s === null || s === undefined) return true;
135
+ }
136
+ }
137
+ return false;
138
+ }
139
+
111
140
  // Push a config object to the server. Caller is responsible for deciding when.
112
141
  export async function pushConfig(apiKey, serverUrl, projectName, payload) {
113
142
  if (!apiKey || !serverUrl || !projectName) return { ok: false, error: "missing_auth" };
@@ -185,3 +214,19 @@ function deepMerge(defaults, overrides) {
185
214
  }
186
215
  return result;
187
216
  }
217
+
218
+ // Like deepMerge, but null/undefined on the overrides side never
219
+ // clobbers a non-null value already in base. Used for the local ← server
220
+ // step so an empty server row can't wipe a fully-populated local file.
221
+ function mergeNonNull(base, overrides) {
222
+ const result = { ...base };
223
+ for (const [key, value] of Object.entries(overrides)) {
224
+ if (value === null || value === undefined) continue;
225
+ if (value && typeof value === "object" && !Array.isArray(value) && base[key] && typeof base[key] === "object") {
226
+ result[key] = mergeNonNull(base[key], value);
227
+ } else {
228
+ result[key] = value;
229
+ }
230
+ }
231
+ return result;
232
+ }
package/cli/init.mjs CHANGED
@@ -484,12 +484,21 @@ async function runWizard({ cwd, apiKey, mode, projectKey }) {
484
484
  }
485
485
  console.log("");
486
486
 
487
- // Write local .agentdesk.json. Existing mode writes only projectKey
488
- // (server owns the rest and loadConfig will re-populate on the next
489
- // run); new mode writes the full config.
487
+ // Write local .agentdesk.json.
488
+ // Existing mode: preserve whatever's already on disk, ensuring
489
+ // only that `projectKey` is set. Never clobber tracker/github/
490
+ // badge fields the user may have locally even when the server
491
+ // hasn't caught up yet (auto-heal push in config.mjs will
492
+ // reconcile the server side).
493
+ // • New mode: write the full config we just built.
490
494
  const configPath = join(currentCwd, ".agentdesk.json");
491
495
  if (isExisting) {
492
- writeFileSync(configPath, JSON.stringify({ projectKey: computedKey }, null, 2) + "\n");
496
+ let existingOnDisk = {};
497
+ if (existsSync(configPath)) {
498
+ try { existingOnDisk = JSON.parse(readFileSync(configPath, "utf-8")); } catch {}
499
+ }
500
+ const merged = { ...existingOnDisk, projectKey: computedKey };
501
+ writeFileSync(configPath, JSON.stringify(merged, null, 2) + "\n");
493
502
  } else {
494
503
  writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
495
504
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@kendoo.agentdesk/agentdesk",
3
- "version": "0.20.2",
3
+ "version": "0.20.3",
4
4
  "description": "AI team orchestrator for Claude Code — run collaborative agent sessions from your terminal",
5
5
  "type": "module",
6
6
  "bin": {