@mulmoclaude/core 5.4.0 → 5.5.1

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.
@@ -60,7 +60,7 @@ window.__MC_VIEW = {
60
60
  getItems: (opts) => Promise, // the ONLY way to read records — see below
61
61
  updateItem: (id, patch) => Promise, // patch declared fields (see Writing records)
62
62
  deleteItem: (id) => Promise, // remove a record (requires allowDelete)
63
- startChat: (prompt, role) => void, // start a new chat seeded with the prompt (sent, not drafted)
63
+ startChat: (prompt, role) => void, // start a new chat — draft, or sent when the view declares allowSendChat
64
64
  t: (key, named) => string, // vue-i18n-style dict lookup (same as desktop)
65
65
  };
66
66
  ```
@@ -220,16 +220,25 @@ only way a remote view "does" anything beyond display:
220
220
  window.__MC_VIEW.startChat("Mark task " + item.id + " as done.");
221
221
  ```
222
222
 
223
- **On the phone the prompt is sent, not drafted** — one press and the turn runs.
224
- A phone has no Enter key to press, so leaving the text sitting in an input box
225
- would simply strand the work. (The desktop custom view drafts by default and
226
- sends only when its `views[]` entry declares `allowSendChat: true`; the desktop
227
- phone-frame preview follows that same declaration, so declare it there too if
228
- you want the preview to behave the way the phone will.)
223
+ **The phone follows the view's `allowSendChat` declaration**, exactly as the
224
+ desktop does — it is read from the `views[]` entry, never from your code:
229
225
 
230
- So whatever you compose becomes an agent turn verbatim, with every tool
231
- available. Build the prompt from your own records, and don't paste text you
232
- fetched from somewhere else into it.
226
+ - **Not declared** — the prompt opens as an **editable draft** in the chat sheet,
227
+ and the user presses Send.
228
+ - **`"allowSendChat": true`** — **one press and the turn runs**.
229
+
230
+ ```jsonc
231
+ { "id": "tasks", "label": "Tasks", "file": "views/tasks.html", "target": "mobile", "allowSendChat": true }
232
+ ```
233
+
234
+ Declare it whenever a button is meant to start work: an undeclared view makes
235
+ the user open the sheet and send by hand on every press. The desktop
236
+ phone-frame preview reads the same declaration, so it behaves the way the phone
237
+ will.
238
+
239
+ With it declared, whatever you compose becomes an agent turn verbatim, with
240
+ every tool available. Build the prompt from your own records, and don't paste
241
+ text you fetched from somewhere else into it.
233
242
 
234
243
  ### Translations — `t`
235
244
 
@@ -442,8 +442,8 @@ to reword.
442
442
  - Whatever you compose becomes an agent turn verbatim, with every tool
443
443
  available. Build the prompt from your own records, and don't paste text you
444
444
  fetched from somewhere else into it.
445
- - **On the phone (`target: "mobile"`) the prompt is always sent**, declared or
446
- not: there is no Enter key to press, so a draft would simply strand the work.
445
+ - **The phone (`target: "mobile"`) follows the same declaration** — undeclared
446
+ drafts, declared sends. See `config/helps/custom-view-remote.md`.
447
447
 
448
448
  Use this — not a hidden flag the user has to reconcile later — whenever a button
449
449
  should _start backend work_: the user stays in the loop through trusted first-
@@ -152,6 +152,45 @@ checked**. Open the body with a line saying the answer came from this lookup and
152
152
  is awaiting maintainer review — it is a draft, not documentation. Never edit
153
153
  `bug-report-faq.md` yourself.
154
154
 
155
+ ## "Failed to authenticate" — the Claude CLI's own login expired
156
+
157
+ ### Symptoms
158
+
159
+ - `[Error] Failed to authenticate: OAuth session expired and could not be refreshed`
160
+ - `[Error] Failed to authenticate. API Error: 401 …` / `Invalid API key`
161
+ - Older versions: that text shown as a normal reply, then a separate `[Error] claude exited with code 1`
162
+ - Every turn fails the same way until the user acts — failures come in bursts, not at random
163
+
164
+ ### Cause
165
+
166
+ The `claude` CLI that MulmoClaude spawns could not authenticate with Anthropic, so it
167
+ exited before running anything. This is the CLI's own login (`claude login`, stored in
168
+ `~/.claude` / the macOS Keychain), not a MulmoClaude or Google account link. An OAuth
169
+ (subscription) session goes stale when its silent refresh fails; nothing on the
170
+ MulmoClaude side can refresh it.
171
+
172
+ ### Fix
173
+
174
+ The user runs `claude /login` (or `claude login`) in a terminal **on the host**, then
175
+ resends the message. If they authenticate with `ANTHROPIC_API_KEY` instead, check that
176
+ key. Switching auth mode is not the fix — a fresh `/login` is.
177
+
178
+ If `/login` succeeds and the error persists (typically `401 OAuth access token is
179
+ invalid`), what to check depends on the sandbox:
180
+
181
+ - **Sandbox off** — the spawned CLI inherits the server's environment, so a
182
+ `CLAUDE_CODE_OAUTH_TOKEN` set where MulmoClaude was started (shell profile or `.env`)
183
+ overrides the stored login. The user unsets or replaces it and **restarts
184
+ MulmoClaude**; a new terminal does not change the running server.
185
+ - **Sandbox on** — the container gets only the variables MulmoClaude passes explicitly,
186
+ and that token is not one of them; it authenticates from the host's mounted
187
+ `~/.claude`. Do not chase the environment variable here — the host login is the one
188
+ that counts.
189
+
190
+ You will usually be reading this AFTER the user re-logged in (a failing turn never
191
+ reaches you); answer "why did that happen" with the cause above rather than
192
+ investigating MulmoClaude's settings.
193
+
155
194
  ## gh / git / SSH errors inside the sandbox
156
195
 
157
196
  ### Symptoms
@@ -39,6 +39,13 @@ export interface CalendarCollectionPushResult {
39
39
  deletedInGoogle: number;
40
40
  /** Records that cannot be pushed as they stand, each with the reason. */
41
41
  skipped: string[];
42
+ /** Deletions the sweep left standing in Google, each with the reason.
43
+ *
44
+ * NOT merged into `skipped`: that list means "this push did not do what you
45
+ * asked", and a refused deletion is "it was deliberately not deleted" while
46
+ * the rest of the run carried. Merging them made one refusal hide every
47
+ * successful create and update from the caller (#3272). */
48
+ keptInGoogle: string[];
42
49
  errors: string[];
43
50
  /** Records whose local edit did NOT reach Google and would be destroyed by
44
51
  * the pull that follows an automatic push (#2620).
@@ -108,9 +115,28 @@ type PushOutcome = {
108
115
  message: string;
109
116
  };
110
117
  export type PushOutcomeKind = PushOutcome["kind"];
118
+ /** One record's push, kept with the id so the pull can be told which records to
119
+ * leave alone. */
120
+ export interface PushAttempt {
121
+ eventId: string;
122
+ outcome: PushOutcome;
123
+ }
111
124
  /** Whether this outcome leaves a local edit that only exists locally, so the
112
125
  * pull must not overwrite the record and the baseline must not advance. */
113
126
  export declare const isUnpushed: (kind: PushOutcomeKind) => boolean;
127
+ /** The whole report one run produces, from what each record's push did and what
128
+ * the delete sweep did.
129
+ *
130
+ * A sweep's refusal goes to `keptInGoogle`, NEVER to `skipped`: that list means
131
+ * "this push did not do what you asked", and a refusal means "the event was
132
+ * deliberately left standing" while the rest of the run carried. Merged, one
133
+ * refusal took the caller's problem branch and hid every create and update
134
+ * (#3272).
135
+ *
136
+ * Exported because it is pure — a list of attempts and a sweep, no workspace,
137
+ * grant or clock — so the separation above is something a test drives rather
138
+ * than argues. */
139
+ export declare function pushResultFrom(slug: string, attempts: readonly PushAttempt[], deletes: DeleteSweep): CalendarCollectionPushResult;
114
140
  /** Record ids whose local value no longer matches the baseline — edits that have
115
141
  * not reached Google.
116
142
  *
@@ -133,6 +159,9 @@ export declare function unsentLocalEdits(collection: LoadedCollection, workspace
133
159
  export interface DeleteSweep {
134
160
  seen: number;
135
161
  deleted: string[];
162
+ /** Deletions left standing in Google, each with the reason. Reported apart
163
+ * from the push's own `skipped` on the result: that list means "could not be
164
+ * sent", and a refusal means "deliberately left alone" (#3272). */
136
165
  skipped: string[];
137
166
  errors: string[];
138
167
  }
@@ -2034,9 +2034,22 @@ var UNPUSHED_KINDS = ["conflict", "error"];
2034
2034
  /** Whether this outcome leaves a local edit that only exists locally, so the
2035
2035
  * pull must not overwrite the record and the baseline must not advance. */
2036
2036
  var isUnpushed = (kind) => UNPUSHED_KINDS.includes(kind);
2037
- function tally(slug, attempts, deletes) {
2037
+ /** The whole report one run produces, from what each record's push did and what
2038
+ * the delete sweep did.
2039
+ *
2040
+ * A sweep's refusal goes to `keptInGoogle`, NEVER to `skipped`: that list means
2041
+ * "this push did not do what you asked", and a refusal means "the event was
2042
+ * deliberately left standing" while the rest of the run carried. Merged, one
2043
+ * refusal took the caller's problem branch and hid every create and update
2044
+ * (#3272).
2045
+ *
2046
+ * Exported because it is pure — a list of attempts and a sweep, no workspace,
2047
+ * grant or clock — so the separation above is something a test drives rather
2048
+ * than argues. */
2049
+ function pushResultFrom(slug, attempts, deletes) {
2038
2050
  const outcomes = attempts.map((attempt) => attempt.outcome);
2039
2051
  const count = (kind) => outcomes.filter((outcome) => outcome.kind === kind).length;
2052
+ const messagesOf = (kind) => outcomes.flatMap((outcome) => outcome.kind === kind ? [outcome.message] : []);
2040
2053
  return {
2041
2054
  slug,
2042
2055
  created: count("created"),
@@ -2044,8 +2057,9 @@ function tally(slug, attempts, deletes) {
2044
2057
  conflicts: count("conflict"),
2045
2058
  localDeletes: deletes.seen,
2046
2059
  deletedInGoogle: deletes.deleted.length,
2047
- skipped: [...outcomes.flatMap((outcome) => outcome.kind === "skipped" ? [outcome.message] : []), ...deletes.skipped],
2048
- errors: [...outcomes.flatMap((outcome) => outcome.kind === "error" ? [outcome.message] : []), ...deletes.errors],
2060
+ skipped: messagesOf("skipped"),
2061
+ keptInGoogle: [...deletes.skipped],
2062
+ errors: [...messagesOf("error"), ...deletes.errors],
2049
2063
  unpushedIds: attempts.filter((attempt) => isUnpushed(attempt.outcome.kind)).map((attempt) => attempt.eventId)
2050
2064
  };
2051
2065
  }
@@ -2175,7 +2189,7 @@ async function pushCollectionNow(collection, workspaceRoot, deps = liveDeps) {
2175
2189
  });
2176
2190
  await saveCalendarShadow(calendarId, pushedShadow([outcome]), workspaceRoot);
2177
2191
  }
2178
- const result = tally(slug, attempts, await sweepDeletes(locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))), schema.googleCalendar?.propagateDeletes === true, {
2192
+ const result = pushResultFrom(slug, attempts, await sweepDeletes(locallyDeletedIds(shadow, records.map((record) => fieldText(record[schema.primaryKey]))), schema.googleCalendar?.propagateDeletes === true, {
2179
2193
  fetchEvent: (eventId) => (deps.fetchEvent ?? getCalendarEvent)(accessToken, {
2180
2194
  calendarId,
2181
2195
  eventId
@@ -2315,7 +2329,7 @@ function pullableEvents(events, unpushed) {
2315
2329
  * A conflict or an error means that record now diverges from Google until
2316
2330
  * someone resolves it, which must not be silent. */
2317
2331
  function reportAutoPush(slug, result) {
2318
- const { created, updated, conflicts, skipped, errors, unpushedIds } = result;
2332
+ const { created, updated, conflicts, skipped, keptInGoogle, errors, unpushedIds } = result;
2319
2333
  if (created + updated > 0) log.info("google", "auto-pushed local calendar edits", {
2320
2334
  slug,
2321
2335
  created,
@@ -2330,6 +2344,10 @@ function reportAutoPush(slug, result) {
2330
2344
  slug,
2331
2345
  skipped
2332
2346
  });
2347
+ if (keptInGoogle.length > 0) log.warn("google", "deletions the auto push left standing in Google", {
2348
+ slug,
2349
+ keptInGoogle
2350
+ });
2333
2351
  if (errors.length > 0) log.warn("google", "auto push errors", {
2334
2352
  slug,
2335
2353
  errors
@@ -3264,6 +3282,7 @@ exports.pullableEvents = pullableEvents;
3264
3282
  exports.pushAndProtect = pushAndProtect;
3265
3283
  exports.pushCalendarForCollection = pushCalendarForCollection;
3266
3284
  exports.pushCollectionNow = pushCollectionNow;
3285
+ exports.pushResultFrom = pushResultFrom;
3267
3286
  exports.pushableMap = pushableMap;
3268
3287
  exports.readDriveFile = readDriveFile;
3269
3288
  exports.releaseOrphanedCalendarToken = releaseOrphanedCalendarToken;