mulmoclaude 0.9.3 → 0.9.4

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 (45) hide show
  1. package/client/assets/{index-40ErrJ4a.js → index-B3NxFcEH.js} +36 -36
  2. package/client/assets/{marp-Dh7C24F1.js → marp-C9QDHFAJ.js} +1 -1
  3. package/client/index.html +1 -1
  4. package/package.json +6 -6
  5. package/server/api/routes/agent.ts +5 -1
  6. package/server/api/routes/collections.ts +16 -1
  7. package/server/api/routes/config.ts +12 -0
  8. package/server/remoteHost/firebase.ts +6 -0
  9. package/server/remoteHost/handlers/index.ts +4 -0
  10. package/server/remoteHost/handlers/ingestAttachments.ts +88 -0
  11. package/server/remoteHost/handlers/listAccountingBooks.ts +31 -0
  12. package/server/remoteHost/handlers/listCollections.ts +6 -1
  13. package/server/remoteHost/handlers/listSkills.ts +39 -0
  14. package/server/remoteHost/handlers/startChat.ts +103 -39
  15. package/server/system/config.ts +101 -4
  16. package/server/system/logs/aaa +737 -0
  17. package/server/system/logs/bb +446 -0
  18. package/server/workspace/chat-index/index.ts +21 -2
  19. package/server/workspace/chat-index/indexer.ts +57 -14
  20. package/server/workspace/chat-index/summarizer.ts +18 -10
  21. package/server/workspace/collections/index.ts +1 -1
  22. package/server/workspace/journal/archivist-cli.ts +17 -3
  23. package/server/workspace/journal/dailyPass.ts +52 -2
  24. package/server/workspace/journal/index.ts +20 -4
  25. package/src/App.vue +5 -0
  26. package/src/components/FileTree.vue +21 -1
  27. package/src/components/FileTreePane.vue +41 -25
  28. package/src/components/FilesView.vue +4 -0
  29. package/src/components/RemoteHostControl.vue +18 -0
  30. package/src/components/SessionHistoryPanel.vue +24 -11
  31. package/src/components/SettingsChatIndexTab.vue +119 -0
  32. package/src/components/SettingsJournalTab.vue +117 -0
  33. package/src/components/SettingsModal.vue +12 -2
  34. package/src/composables/collections/useDynamicShortcutIcons.ts +93 -0
  35. package/src/composables/useShowHiddenSystemFiles.ts +23 -0
  36. package/src/config/visibleWorkspaceDirs.ts +21 -0
  37. package/src/lang/de.ts +45 -0
  38. package/src/lang/en.ts +43 -0
  39. package/src/lang/es.ts +44 -0
  40. package/src/lang/fr.ts +45 -0
  41. package/src/lang/ja.ts +44 -0
  42. package/src/lang/ko.ts +43 -0
  43. package/src/lang/pt-BR.ts +44 -0
  44. package/src/lang/zh.ts +43 -0
  45. package/src/utils/session/sessionPreview.ts +29 -0
@@ -1,78 +1,142 @@
1
1
  // startChat command handler (remote-host — start a chat from the mobile remote).
2
2
  //
3
- // Fire-and-forget: composes the collection's slash command as a prefix on the
4
- // user's message (`/<slug> <message>` for a collection, `/<slug> id=<itemId>
5
- // <message>` for one record), then spawns a VISIBLE host chat session (origin
6
- // `skill`, openable from history) via spawnSystemWorker, and returns the new
7
- // chatId. No streaming back — starting the chat on the host is enough.
3
+ // The remote sends text; the host starts a new VISIBLE chat session (origin
4
+ // `skill`, openable from desktop history) seeded with it, and returns the new
5
+ // chatId. Fire-and-forget: no streaming back starting the chat on the host is
6
+ // enough.
8
7
  //
9
- // Mirrors the desktop's two entry points over the command channel: the
10
- // collection-level `__MC_VIEW.startChat` and the per-record chat box in
11
- // CollectionRecordPanel.vue (`/<slug> id=<itemId> <message>`).
8
+ // ┌─────────────────────────────────────────────────────────────────────────┐
9
+ // THE CONTRACT read this before touching the params. │
10
+ // │ │
11
+ // │ CURRENT clients send ONLY `{ message }`. The message is seeded VERBATIM │
12
+ // │ as the first user turn. The host does NOT interpret it, so the client is │
13
+ // │ free to put a slash command (`/<slug> …`), a plain question, or anything │
14
+ // │ else in the text. This is the ONLY form new code should emit. │
15
+ // │ │
16
+ // │ `slug` (and its optional companion `itemId`) is LEGACY-ONLY. Older │
17
+ // │ clients still send `{ slug, itemId?, message }` to target a collection │
18
+ // │ or one record; we keep composing `/<slug> [id=<itemId>] <message>` for │
19
+ // │ them so they don't break. DO NOT add new features to this branch, and DO │
20
+ // │ NOT teach new clients to send `slug` — put the slash command in │
21
+ // │ `message` instead. The whole legacy path can be deleted once no deployed │
22
+ // │ client sends `slug` anymore. │
23
+ // └─────────────────────────────────────────────────────────────────────────┘
12
24
  //
13
- // Role is intentionally NOT part of the remote API the caller passes only
14
- // message + slug + optional itemId. The host runs the chat in its default role
15
- // (spawnSystemWorker startChat requires a concrete roleId; empty is rejected).
25
+ // Optional `role` the id of the role the chat should run in (built-in or
26
+ // custom). Absent / null / "" the host default role. A provided id MUST match
27
+ // an existing role: spawnSystemWorker requires a concrete roleId and the
28
+ // downstream getRole silently falls back to `general` on a miss, so we validate
29
+ // here and reject an unknown role rather than seed the wrong assistant.
16
30
  //
17
- // Feeds are refused: a feed (`source === "feed"`) has no `/<slug>` skill
18
- // command, so a slash-command seed would invoke nothing (see desktop
19
- // `CollectionView.buildChatSeed`). The mobile remote doesn't offer chat for
20
- // feeds; this is the host-side backstop so a stray feed slug can't produce a
21
- // broken chat.
31
+ // Optional `attachments` full-res files (photos, videos, PDFs) the remote
32
+ // staged to Storage, carried as `[{ storage_id }]`. The host ingests them into
33
+ // the workspace (ingestAttachments) and hands the resulting path-only
34
+ // Attachments to the spawned chat. Absent / empty byte-for-byte the prior
35
+ // text-only behaviour.
22
36
  //
23
37
  // Factory (createStartChat) keeps composition/wiring unit-testable with the
24
38
  // engine + spawner stubbed; the default export wires the real ones.
25
39
  import { spawnSystemWorker } from "../../api/routes/agent.js";
26
40
  import { loadCollection } from "../../workspace/collections/index.js";
41
+ import { loadAllRoles } from "../../workspace/roles.js";
27
42
  import { DEFAULT_ROLE_ID } from "../../../src/config/roles.js";
28
43
  import type { CommandHandler, JsonObject, JsonValue } from "../commandChannel.js";
44
+ import { ingestAttachments } from "./ingestAttachments.js";
29
45
 
30
46
  export interface StartChatDeps {
31
47
  spawn: typeof spawnSystemWorker;
32
48
  loadCollection: typeof loadCollection;
49
+ ingest: typeof ingestAttachments;
50
+ loadRoles: typeof loadAllRoles;
33
51
  }
34
52
 
35
- // Prefix the message with the collection's slash command. `itemId` scopes the
36
- // chat to one record; empty the whole collection. Matches the desktop
37
- // item-chat format documented in CollectionRecordPanel.vue.
53
+ // Parse the optional `attachments` param into a list of storage_ids. Absent no
54
+ // attachments. A malformed shape (not an array, or an element without a string
55
+ // `storage_id`) rejects the whole command: the remote already uploaded the
56
+ // bytes and is waiting, so a surfaced error beats a chat with a missing file.
57
+ const readStorageIds = (attachments: JsonValue | undefined): string[] => {
58
+ if (attachments == null) return [];
59
+ if (!Array.isArray(attachments)) throw new Error("attachments must be an array of { storage_id }");
60
+ return attachments.map((entry) => {
61
+ const storageId = entry && typeof entry === "object" && !Array.isArray(entry) ? entry.storage_id : undefined;
62
+ if (typeof storageId !== "string" || storageId.length === 0) throw new Error("each attachments entry must be { storage_id: string }");
63
+ return storageId;
64
+ });
65
+ };
66
+
67
+ // LEGACY. Prefix the message with the collection's slash command. `itemId`
68
+ // scopes the chat to one record; empty ⇒ the whole collection. Matches the
69
+ // desktop item-chat format documented in CollectionRecordPanel.vue. Only the
70
+ // legacy `slug` path calls this — new clients put the slash command in
71
+ // `message` themselves.
38
72
  export const composeMessage = (slug: string, itemId: string, message: string): string => {
39
73
  const prefix = itemId ? `/${slug} id=${itemId}` : `/${slug}`;
40
74
  return `${prefix} ${message}`;
41
75
  };
42
76
 
43
- // slug and itemId become single tokens in the slash command (`/<slug>`,
44
- // `id=<itemId>`), so a whitespace-containing or non-string value would break
45
- // the command parse (e.g. `/ hello`). Accept only a trimmed, whitespace-free
46
- // string; anything else ⇒ "" so the caller rejects it.
77
+ // LEGACY. slug and itemId become single tokens in the slash command
78
+ // (`/<slug>`, `id=<itemId>`), so a whitespace-containing or non-string value
79
+ // would break the command parse (e.g. `/ hello`). Accept only a trimmed,
80
+ // whitespace-free string; anything else ⇒ "" so the caller rejects it.
47
81
  const asToken = (value: JsonValue): string => {
48
82
  if (typeof value !== "string") return "";
49
83
  const trimmed = value.trim();
50
84
  return /\s/.test(trimmed) ? "" : trimmed;
51
85
  };
52
86
 
87
+ // LEGACY collection/record targeting for older clients that still send `slug`.
88
+ // Compose the `/<slug> [id=<itemId>] <message>` seed the desktop uses; reject a
89
+ // malformed or unknown slug and refuse feeds, so we never seed a `/<slug>`
90
+ // command that resolves to nothing. New clients skip all of this by sending the
91
+ // slash command (or plain text) in `message`.
92
+ const composeCollectionSeed = async (deps: StartChatDeps, params: JsonObject, message: string): Promise<string> => {
93
+ const slug = asToken(params.slug);
94
+ const itemId = asToken(params.itemId);
95
+ if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
96
+ if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
97
+ const target = await deps.loadCollection(slug);
98
+ if (!target) throw new Error(`collection '${slug}' not found`);
99
+ if (target.source === "feed") throw new Error("chat is not available for feeds");
100
+ return composeMessage(slug, itemId, message);
101
+ };
102
+
103
+ // A `slug` is "provided" (⇒ LEGACY targeting) only when it is a non-empty
104
+ // value. Omitted / null / "" ⇒ the current free-text form. New clients never
105
+ // set it, so they always take the verbatim branch below.
106
+ const hasSlug = (value: JsonValue | undefined): boolean => value != null && value !== "";
107
+
108
+ // Resolve the optional `role` param to a concrete roleId. Absent / null / "" ⇒
109
+ // the host default. A provided id must be a string that matches an existing
110
+ // role (built-in or custom) — reject an unknown one so we never seed the chat
111
+ // with the wrong (default-fallback) assistant. `isDebugRole` roles are excluded:
112
+ // the desktop picker hides them from new sessions outside dev mode
113
+ // (RoleSelector.vue), and the remote channel is a production-facing entry point,
114
+ // so a debug role id is treated as not selectable here (rejected as unknown).
115
+ const resolveRoleId = (deps: StartChatDeps, role: JsonValue | undefined): string => {
116
+ if (role == null || role === "") return DEFAULT_ROLE_ID;
117
+ if (typeof role !== "string") throw new Error("role must be a string");
118
+ if (!deps.loadRoles().some((candidate) => candidate.id === role && !candidate.isDebugRole)) throw new Error(`role '${role}' not found`);
119
+ return role;
120
+ };
121
+
53
122
  export const createStartChat =
54
123
  (deps: StartChatDeps): CommandHandler =>
55
124
  async (params: JsonObject) => {
56
125
  // Params arrive as JSON over the channel — coerce/validate defensively.
57
- const slug = asToken(params.slug);
58
- const itemId = asToken(params.itemId);
59
126
  const message = (typeof params.message === "string" ? params.message : "").trim();
60
- if (!slug) throw new Error("slug must be a non-empty, whitespace-free string");
61
- if (params.itemId != null && !itemId) throw new Error("itemId must be a non-empty, whitespace-free string when provided");
62
127
  if (!message) throw new Error("message is required");
63
- // Resolve the target so we never seed a `/<slug>` command that resolves to
64
- // nothing: an unknown/stale slug (`null`) is rejected, and a feed
65
- // (`source === "feed"`, no skill command) is refused.
66
- const target = await deps.loadCollection(slug);
67
- if (!target) throw new Error(`collection '${slug}' not found`);
68
- if (target.source === "feed") throw new Error("chat is not available for feeds");
69
- const result = await deps.spawn({
70
- message: composeMessage(slug, itemId, message),
71
- roleId: DEFAULT_ROLE_ID,
72
- hidden: false,
73
- });
128
+ // Current clients: `message` only seed it verbatim. Legacy clients that
129
+ // still send `slug` compose the old `/<slug> [id=<itemId>] <message>` seed.
130
+ const seed = hasSlug(params.slug) ? await composeCollectionSeed(deps, params, message) : message;
131
+ // Resolve the role BEFORE ingest/spawn so an unknown role rejects the
132
+ // command without staging work or launching a chat.
133
+ const roleId = resolveRoleId(deps, params.role);
134
+ // Ingest any staged files BEFORE spawning so a download/validation failure
135
+ // rejects the command instead of starting a chat missing its attachments.
136
+ const attachments = await deps.ingest(readStorageIds(params.attachments));
137
+ const result = await deps.spawn({ message: seed, roleId, hidden: false, attachments });
74
138
  if (!result.ok) throw new Error(result.error);
75
139
  return { started: true, chatId: result.chatId };
76
140
  };
77
141
 
78
- export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection });
142
+ export const startChat = createStartChat({ spawn: spawnSystemWorker, loadCollection, ingest: ingestAttachments, loadRoles: loadAllRoles });
@@ -23,6 +23,27 @@ import { isRecord, isStringArray, isStringRecord } from "../utils/types.js";
23
23
  export const EFFORT_LEVELS = ["low", "medium", "high", "xhigh", "max"] as const;
24
24
  export type EffortLevel = (typeof EFFORT_LEVELS)[number];
25
25
 
26
+ // Chat-index summarizer setting (#1944). "off" disables the whole
27
+ // AI-title / summary / keywords background indexer; "haiku" / "sonnet"
28
+ // select the Claude model the summarizer spawns. Default (undefined)
29
+ // is treated as "off" — indexing is opt-in from Settings → Chat index
30
+ // so a fresh workspace doesn't burn CLI budget on scheduler/system
31
+ // automation sessions the user may never look at (see #1929 / #1944
32
+ // for the cost analysis). The origin filter (system + scheduler
33
+ // sessions always skipped) is enforced separately and does NOT
34
+ // depend on this setting.
35
+ export const CHAT_INDEX_MODES = ["off", "haiku", "sonnet"] as const;
36
+ export type ChatIndexMode = (typeof CHAT_INDEX_MODES)[number];
37
+
38
+ // Journal daily-pass setting (follow-up to #1944). Same three states as
39
+ // chat-index: "off" disables both the turn-end hook and the hourly
40
+ // scheduled pass; "haiku" / "sonnet" pick the Claude model the archivist
41
+ // CLI spawns for its summary calls. Default (undefined) is "off" so a
42
+ // fresh workspace doesn't burn CLI budget until the user opts in from
43
+ // Settings → Journal.
44
+ export const JOURNAL_MODES = ["off", "haiku", "sonnet"] as const;
45
+ export type JournalMode = (typeof JOURNAL_MODES)[number];
46
+
26
47
  export interface AppSettings {
27
48
  // Extra tool names appended to BASE_ALLOWED_TOOLS in
28
49
  // server/agent/config.ts#buildCliArgs. Typical entries are
@@ -63,6 +84,24 @@ export interface AppSettings {
63
84
  enabled: boolean;
64
85
  model?: string;
65
86
  };
87
+
88
+ // Chat-index summarizer mode (#1944). Ships "off" by default: the
89
+ // background AI-title / summary / keywords indexer stays disabled
90
+ // until the user opts in from Settings → Chat index. Flipping to
91
+ // "haiku" or "sonnet" selects the model the summarizer spawns per
92
+ // session. Non-user origins (`system`, `scheduler`) are always
93
+ // skipped regardless of this value — see `indexer.ts`.
94
+ chatIndex?: ChatIndexMode;
95
+
96
+ // Journal daily-pass mode (follow-up to #1944). Ships "off" by
97
+ // default: the archivist that summarizes chat sessions into
98
+ // journal/*.md stays disabled until the user opts in from Settings →
99
+ // Journal. Flipping to "haiku" / "sonnet" enables the run and picks
100
+ // the Claude model the archivist CLI spawns. The turn-end hook
101
+ // (`maybeRunJournal` in the agent finally block) and the hourly
102
+ // `system:journal` scheduled task both honour this — off short-
103
+ // circuits before the interval gate is even consulted.
104
+ journal?: JournalMode;
66
105
  }
67
106
 
68
107
  const DEFAULT_SETTINGS: AppSettings = { extraAllowedTools: [] };
@@ -94,6 +133,14 @@ function isEffortLevel(value: unknown): value is EffortLevel {
94
133
  return typeof value === "string" && (EFFORT_LEVELS as readonly string[]).includes(value);
95
134
  }
96
135
 
136
+ function isChatIndexMode(value: unknown): value is ChatIndexMode {
137
+ return typeof value === "string" && (CHAT_INDEX_MODES as readonly string[]).includes(value);
138
+ }
139
+
140
+ function isJournalMode(value: unknown): value is JournalMode {
141
+ return typeof value === "string" && (JOURNAL_MODES as readonly string[]).includes(value);
142
+ }
143
+
97
144
  function isVoiceInputSettings(value: unknown): value is { enabled: boolean; model?: string } {
98
145
  if (!isRecord(value)) return false;
99
146
  if (typeof value.enabled !== "boolean") return false;
@@ -108,9 +155,13 @@ export function isAppSettings(value: unknown): value is AppSettings {
108
155
  if (value.photoExif !== undefined && !isPhotoExifSettings(value.photoExif)) return false;
109
156
  if (value.effortLevel !== undefined && !isEffortLevel(value.effortLevel)) return false;
110
157
  if (value.voiceInput !== undefined && !isVoiceInputSettings(value.voiceInput)) return false;
158
+ if (value.chatIndex !== undefined && !isChatIndexMode(value.chatIndex)) return false;
159
+ if (value.journal !== undefined && !isJournalMode(value.journal)) return false;
111
160
  return true;
112
161
  }
113
162
 
163
+ // isAppSettingsPatch adds a null sentinel to `chatIndex` / `journal` (see below).
164
+
114
165
  /** A PUT-payload validator: every field optional, but if present
115
166
  * it must match the AppSettings shape. Distinct from
116
167
  * `isAppSettings` (which insists on the full storage shape) so a
@@ -127,28 +178,46 @@ export function isAppSettings(value: unknown): value is AppSettings {
127
178
  * but lets nullable fields carry `null` as a "clear me" sentinel —
128
179
  * callers normalise via `normaliseAppSettingsPatch` before merging
129
180
  * into the storage shape (#1323). */
130
- export type AppSettingsPatch = Partial<Omit<AppSettings, "effortLevel">> & {
181
+ export type AppSettingsPatch = Partial<Omit<AppSettings, "effortLevel" | "chatIndex" | "journal">> & {
131
182
  effortLevel?: EffortLevel | null;
183
+ chatIndex?: ChatIndexMode | null;
184
+ journal?: JournalMode | null;
132
185
  };
133
186
 
134
187
  /** Convert a wire patch to the storage-shape patch by dropping any
135
188
  * `null` sentinels (which mean "clear" for the corresponding field). */
136
189
  export function normaliseAppSettingsPatch(patch: AppSettingsPatch): Partial<AppSettings> {
137
- const { effortLevel, ...rest } = patch;
190
+ const { effortLevel, chatIndex, journal, ...rest } = patch;
138
191
  const out: Partial<AppSettings> = { ...rest };
139
192
  if (effortLevel !== null && effortLevel !== undefined) {
140
193
  out.effortLevel = effortLevel;
141
194
  }
195
+ if (chatIndex !== null && chatIndex !== undefined) {
196
+ out.chatIndex = chatIndex;
197
+ }
198
+ if (journal !== null && journal !== undefined) {
199
+ out.journal = journal;
200
+ }
142
201
  return out;
143
202
  }
144
203
 
204
+ // Split each field's optional-with-null-sentinel validation into its own
205
+ // mini-helper so `isAppSettingsPatch` stays under the cognitive-complexity
206
+ // ceiling as new nullable fields land.
207
+ const isOptionalString = (value: unknown): boolean => value === undefined || typeof value === "string";
208
+ const isOptionalNullableEffortLevel = (value: unknown): boolean => value === undefined || value === null || isEffortLevel(value);
209
+ const isOptionalNullableChatIndexMode = (value: unknown): boolean => value === undefined || value === null || isChatIndexMode(value);
210
+ const isOptionalNullableJournalMode = (value: unknown): boolean => value === undefined || value === null || isJournalMode(value);
211
+
145
212
  export function isAppSettingsPatch(value: unknown): value is AppSettingsPatch {
146
213
  if (!isRecord(value)) return false;
147
214
  if (value.extraAllowedTools !== undefined && !isStringArray(value.extraAllowedTools)) return false;
148
- if (value.googleMapsApiKey !== undefined && typeof value.googleMapsApiKey !== "string") return false;
215
+ if (!isOptionalString(value.googleMapsApiKey)) return false;
149
216
  if (value.photoExif !== undefined && !isPhotoExifSettings(value.photoExif)) return false;
150
- if (value.effortLevel !== undefined && value.effortLevel !== null && !isEffortLevel(value.effortLevel)) return false;
217
+ if (!isOptionalNullableEffortLevel(value.effortLevel)) return false;
151
218
  if (value.voiceInput !== undefined && !isVoiceInputSettings(value.voiceInput)) return false;
219
+ if (!isOptionalNullableChatIndexMode(value.chatIndex)) return false;
220
+ if (!isOptionalNullableJournalMode(value.journal)) return false;
152
221
  return true;
153
222
  }
154
223
 
@@ -182,9 +251,31 @@ function cloneAppSettings(settings: AppSettings): AppSettings {
182
251
  copy.voiceInput.model = settings.voiceInput.model;
183
252
  }
184
253
  }
254
+ if (settings.chatIndex !== undefined) {
255
+ copy.chatIndex = settings.chatIndex;
256
+ }
257
+ if (settings.journal !== undefined) {
258
+ copy.journal = settings.journal;
259
+ }
185
260
  return copy;
186
261
  }
187
262
 
263
+ /** Chat-index mode with the documented "undefined → off" default, so
264
+ * every reader (indexer, backfill scheduler, force-startup switch,
265
+ * Settings tab) resolves the same way. Callers switch on the
266
+ * returned literal string to decide skip vs pick a model. */
267
+ export function chatIndexMode(settings: AppSettings): ChatIndexMode {
268
+ return settings.chatIndex ?? "off";
269
+ }
270
+
271
+ /** Journal mode with the documented "undefined → off" default, so
272
+ * every reader (turn-end hook, hourly scheduler, force-startup
273
+ * switch, Settings tab) resolves the same way. Callers switch on
274
+ * the returned literal string to decide skip vs pick a model. */
275
+ export function journalMode(settings: AppSettings): JournalMode {
276
+ return settings.journal ?? "off";
277
+ }
278
+
188
279
  /** Read the photo-exif auto-capture flag with the documented
189
280
  * default of `true`. Centralises the "missing block ⇒ on" rule so
190
281
  * the post-save hook + future Settings UI stay aligned. */
@@ -234,6 +325,12 @@ export function saveSettings(settings: AppSettings): void {
234
325
  payload.voiceInput.model = settings.voiceInput.model;
235
326
  }
236
327
  }
328
+ if (settings.chatIndex !== undefined) {
329
+ payload.chatIndex = settings.chatIndex;
330
+ }
331
+ if (settings.journal !== undefined) {
332
+ payload.journal = settings.journal;
333
+ }
237
334
  const serialised = JSON.stringify(payload, null, 2);
238
335
  writeFileAtomicSync(settingsPath(), `${serialised}\n`, { mode: 0o600 });
239
336
  }