mulmoclaude 0.9.6 → 0.9.7

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/client/index.html CHANGED
@@ -43,12 +43,12 @@
43
43
  }
44
44
  }
45
45
  </script>
46
- <script type="module" crossorigin src="/assets/index-BLM4Fy-7.js"></script>
46
+ <script type="module" crossorigin src="/assets/index-C29dYZy5.js"></script>
47
47
  <link rel="modulepreload" crossorigin href="/assets/rolldown-runtime-CNC7AqOf.js">
48
48
  <link rel="modulepreload" crossorigin href="/assets/vue.runtime.esm-bundler-D9nG8JAr.js">
49
49
  <link rel="modulepreload" crossorigin href="/assets/vue-i18n-DMHgtTtA.js">
50
50
  <link rel="modulepreload" crossorigin href="/assets/vue-CU5-3-x4.js">
51
- <link rel="modulepreload" crossorigin href="/assets/PluginScopedRoot-BBS0j7lL.js">
51
+ <link rel="modulepreload" crossorigin href="/assets/PluginScopedRoot-BhiOgLTT.js">
52
52
  <link rel="modulepreload" crossorigin href="/assets/schemas-DGvl73AE.js">
53
53
  <link rel="modulepreload" crossorigin href="/assets/v4-DDdyfk2q.js">
54
54
  <link rel="modulepreload" crossorigin href="/assets/preload-helper-Czpn1I53.js">
@@ -57,7 +57,7 @@
57
57
  <link rel="modulepreload" crossorigin href="/assets/_plugin-vue_export-helper-BDNMzG2s.js">
58
58
  <link rel="modulepreload" crossorigin href="/assets/chunk-D8eiyYIV-D55tgtzm.js">
59
59
  <link rel="modulepreload" crossorigin href="/assets/typeof-DBp4T-Ny-B5XbjTb1.js">
60
- <link rel="stylesheet" crossorigin href="/assets/index-fGG7U2mb.css">
60
+ <link rel="stylesheet" crossorigin href="/assets/index-Ctc1Sbhu.css">
61
61
  </head>
62
62
  <body>
63
63
  <div id="app"></div>
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mulmoclaude",
3
- "version": "0.9.6",
3
+ "version": "0.9.7",
4
4
  "description": "MulmoClaude — GUI-chat with Claude Code + long-term memory. One command to start.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -36,7 +36,7 @@
36
36
  "@mulmoclaude/accounting-plugin": "^0.3.2",
37
37
  "@mulmoclaude/chart-plugin": "^0.1.3",
38
38
  "@mulmoclaude/collection-plugin": "^0.7.5",
39
- "@mulmoclaude/core": "^0.12.2",
39
+ "@mulmoclaude/core": "^0.13.0",
40
40
  "@mulmoclaude/form-plugin": "^0.1.4",
41
41
  "@mulmoclaude/html-plugin": "^0.2.5",
42
42
  "@mulmoclaude/markdown-plugin": "^0.1.10",
@@ -48,7 +48,7 @@
48
48
  "exifr": "^7.1.3",
49
49
  "express": "^5.2.1",
50
50
  "fast-xml-parser": "^5.10.0",
51
- "firebase": "12.15.0",
51
+ "firebase": "12.16.0",
52
52
  "gui-chat-protocol": "0.4.0",
53
53
  "heic-convert": "^2.1.0",
54
54
  "ignore": "^7.0.6",
@@ -56,13 +56,13 @@
56
56
  "jszip": "^3.10.1",
57
57
  "mammoth": "^1.12.0",
58
58
  "marked": "^18.0.6",
59
- "mulmocast": "^2.7.1",
59
+ "mulmocast": "^2.7.2",
60
60
  "node-html-parser": "^9",
61
61
  "puppeteer": "^25.3.0",
62
62
  "sharp": "^0.35.3",
63
63
  "socket.io": "^4.8.3",
64
64
  "socket.io-client": "^4.8.3",
65
- "tsx": "^4.23.0",
65
+ "tsx": "^4.23.1",
66
66
  "uuid": "^14.0.1",
67
67
  "which": "^6",
68
68
  "ws": "^8.21.0",
@@ -1,20 +1,24 @@
1
- // HTTP routes for the remote-host runner (phase 1).
1
+ // HTTP routes for the remote-host runner.
2
2
  //
3
- // POST /api/remote-host/connect { idToken } → { status }
4
- // POST /api/remote-host/disconnect → { status }
5
- // GET /api/remote-host/status → { status }
3
+ // POST /api/remote-host/connect { idToken } → { status, session }
4
+ // POST /api/remote-host/reconnect { session } → { status, session }
5
+ // POST /api/remote-host/disconnect → { status, session: null }
6
+ // GET /api/remote-host/status → { status, session }
6
7
  //
7
8
  // connect signs in to Firebase as the user with the browser-minted Google ID
8
- // token and starts the host runner (command loop + presence heartbeat);
9
- // disconnect stops it and signs out. Bearer-guarded like every other /api route
10
- // (the browser's apiPost attaches the token); the idToken is a secret carried
11
- // in the POST body over the loopback listener only never logged.
9
+ // token and starts the host runner (command loop + presence heartbeat); reconnect
10
+ // restores a browser-parked session blob (case A', mulmoserver#50) without a
11
+ // popup; disconnect stops the runner and signs out. Every response carries the
12
+ // current session blob so the browser can keep its localStorage copy fresh (null
13
+ // when disconnected). Bearer-guarded like every /api route (the browser's apiPost
14
+ // attaches the token); the idToken and session blob are secrets carried in the
15
+ // POST body over the loopback listener only — never logged.
12
16
  import { Router, Request, Response } from "express";
13
17
 
14
18
  import { API_ROUTES } from "../../../src/config/apiRoutes.js";
15
- import { connect, disconnect, status, type RemoteHostStatus } from "../../remoteHost/index.js";
19
+ import { connect, disconnect, exportSession, reconnect, RemoteHostSessionExpiredError, status, type RemoteHostStatus } from "../../remoteHost/index.js";
16
20
  import { errorMessage } from "../../utils/errors.js";
17
- import { badRequest, serverError } from "../../utils/httpError.js";
21
+ import { badRequest, serverError, unauthorized } from "../../utils/httpError.js";
18
22
  import { log } from "../../system/logger/index.js";
19
23
 
20
24
  const PREFIX = "remote-host";
@@ -22,8 +26,14 @@ const router = Router();
22
26
 
23
27
  interface StatusResponse {
24
28
  status: RemoteHostStatus;
29
+ // The session blob the browser parks in localStorage; null when disconnected.
30
+ session: string | null;
25
31
  }
26
32
 
33
+ const respond = (res: Response<StatusResponse>, hostStatus: RemoteHostStatus): void => {
34
+ res.json({ status: hostStatus, session: exportSession() });
35
+ };
36
+
27
37
  router.post(API_ROUTES.remoteHost.connect, async (req: Request, res: Response<StatusResponse>) => {
28
38
  const idToken = typeof req.body?.idToken === "string" ? req.body.idToken.trim() : "";
29
39
  if (!idToken) {
@@ -31,7 +41,7 @@ router.post(API_ROUTES.remoteHost.connect, async (req: Request, res: Response<St
31
41
  return;
32
42
  }
33
43
  try {
34
- res.json({ status: await connect(idToken) });
44
+ respond(res, await connect(idToken));
35
45
  } catch (err) {
36
46
  // Never include idToken in the message.
37
47
  log.warn(PREFIX, "connect failed", { error: errorMessage(err) });
@@ -39,9 +49,32 @@ router.post(API_ROUTES.remoteHost.connect, async (req: Request, res: Response<St
39
49
  }
40
50
  });
41
51
 
52
+ router.post(API_ROUTES.remoteHost.reconnect, async (req: Request, res: Response<StatusResponse>) => {
53
+ const session = typeof req.body?.session === "string" ? req.body.session : "";
54
+ if (!session) {
55
+ badRequest(res, "Request body must be { session: string }");
56
+ return;
57
+ }
58
+ try {
59
+ respond(res, await reconnect(session));
60
+ } catch (err) {
61
+ // Only a genuinely expired/invalid blob is 401 (client drops it and falls
62
+ // back to a normal connect). Transient failures (network, backend, Firebase
63
+ // init) are 5xx so the client KEEPS the blob and can retry later — a blip
64
+ // must not force a re-login. Never log the blob.
65
+ if (err instanceof RemoteHostSessionExpiredError) {
66
+ log.info(PREFIX, "reconnect rejected (blob expired)");
67
+ unauthorized(res, "remote-host session could not be restored");
68
+ return;
69
+ }
70
+ log.warn(PREFIX, "reconnect failed", { error: errorMessage(err) });
71
+ serverError(res, errorMessage(err, "remote-host reconnect failed"));
72
+ }
73
+ });
74
+
42
75
  router.post(API_ROUTES.remoteHost.disconnect, async (_req: Request, res: Response<StatusResponse>) => {
43
76
  try {
44
- res.json({ status: await disconnect() });
77
+ respond(res, await disconnect());
45
78
  } catch (err) {
46
79
  log.warn(PREFIX, "disconnect failed", { error: errorMessage(err) });
47
80
  serverError(res, errorMessage(err, "remote-host disconnect failed"));
@@ -49,7 +82,7 @@ router.post(API_ROUTES.remoteHost.disconnect, async (_req: Request, res: Respons
49
82
  });
50
83
 
51
84
  router.get(API_ROUTES.remoteHost.status, (_req: Request, res: Response<StatusResponse>) => {
52
- res.json({ status: status() });
85
+ respond(res, status());
53
86
  });
54
87
 
55
88
  export default router;
@@ -18,8 +18,7 @@ import type { Attachment } from "@mulmobridge/protocol";
18
18
  import { saveAttachment } from "../../utils/files/attachment-store.js";
19
19
  import { errorMessage } from "../../utils/errors.js";
20
20
  import { log } from "../../system/logger/index.js";
21
- import { currentUid } from "../auth.js";
22
- import { storage } from "../firebase.js";
21
+ import { currentStorage, currentUid } from "../session.js";
23
22
 
24
23
  const PREFIX = "remote-host";
25
24
 
@@ -75,7 +74,7 @@ export const createIngestAttachments =
75
74
  // Pull an object's bytes + content type from Storage. `getBytes` (not the
76
75
  // browser-only `getBlob`) returns an ArrayBuffer that works on the Node host.
77
76
  const fetchObject = async (storagePath: string): Promise<{ base64: string; contentType: string }> => {
78
- const objectRef = ref(storage, storagePath);
77
+ const objectRef = ref(currentStorage(), storagePath);
79
78
  const [bytes, metadata] = await Promise.all([getBytes(objectRef, MAX_DOWNLOAD_BYTES), getMetadata(objectRef)]);
80
79
  return { base64: Buffer.from(bytes).toString("base64"), contentType: metadata.contentType ?? "application/octet-stream" };
81
80
  };
@@ -84,5 +83,5 @@ export const ingestAttachments = createIngestAttachments({
84
83
  uid: currentUid,
85
84
  fetchObject,
86
85
  saveAttachment,
87
- deleteObject: (storagePath) => deleteObject(ref(storage, storagePath)),
86
+ deleteObject: (storagePath) => deleteObject(ref(currentStorage(), storagePath)),
88
87
  });
@@ -7,30 +7,33 @@
7
7
  // supplies host specifics — hostId, the handler table, the firestore-bound
8
8
  // runner, and a logger adapter — and exposes the default singleton the route uses.
9
9
  //
10
- // Single-account, single-host (HOST_ID = "mulmoclaude"), in-memory session: a
11
- // server restart drops the session and needs a re-connect.
10
+ // Single-account, single-host (HOST_ID = "mulmoclaude"). The Firebase session is
11
+ // parked in the browser (case A', mulmoserver#50), so a server restart doesn't
12
+ // force a re-login: the client reconnects from its stored blob.
12
13
  import { createRemoteHost, startHostRunner } from "@mulmoclaude/core/remote-host/server";
13
14
 
14
15
  import { log } from "../system/logger/index.js";
15
16
  import { HOST_ID } from "./commandChannel.js";
16
- import { currentUid, signInHost, signOutHost } from "./auth.js";
17
- import { firestore } from "./firebase.js";
17
+ import { currentFirestore, currentUid, restore, signIn, signOut } from "./session.js";
18
18
  import { handlers } from "./handlers/index.js";
19
19
  import { onExpire } from "./onExpire.js";
20
20
 
21
21
  export type { RemoteHostStatus } from "@mulmoclaude/core/remote-host/server";
22
+ export { exportSession, RemoteHostSessionExpiredError } from "./session.js";
22
23
 
23
24
  const PREFIX = "remote-host";
24
25
 
25
- // Default singleton wired to the real Firebase deps — imported by the route. The
26
- // runner is pre-bound with this host's firestore instance; the logger adapts the
27
- // factory's plain-string calls to the host logger's (prefix, msg) shape.
26
+ // Default singleton wired to the session-backed Firebase deps — imported by the
27
+ // route. The runner reads the CURRENT session's firestore (it changes each
28
+ // (re)connect); the logger adapts the factory's plain-string calls to the host
29
+ // logger's (prefix, msg) shape.
28
30
  const instance = createRemoteHost({
29
31
  hostId: HOST_ID,
30
- signIn: signInHost,
31
- signOut: signOutHost,
32
+ signIn,
33
+ restore,
34
+ signOut,
32
35
  currentUid,
33
- startRunner: (channel, hostHandlers, options) => startHostRunner(firestore, channel, hostHandlers, options),
36
+ startRunner: (channel, hostHandlers, options) => startHostRunner(currentFirestore(), channel, hostHandlers, options),
34
37
  handlers,
35
38
  onExpire,
36
39
  log: {
@@ -40,4 +43,4 @@ const instance = createRemoteHost({
40
43
  },
41
44
  });
42
45
 
43
- export const { connect, disconnect, status } = instance;
46
+ export const { connect, reconnect, disconnect, status } = instance;
@@ -20,7 +20,7 @@ import type { Command, JsonObject } from "@mulmoclaude/core/remote-host";
20
20
 
21
21
  import { errorMessage } from "../utils/errors.js";
22
22
  import { log } from "../system/logger/index.js";
23
- import { storage } from "./firebase.js";
23
+ import { currentStorage } from "./session.js";
24
24
 
25
25
  const PREFIX = "remote-host";
26
26
 
@@ -42,7 +42,7 @@ const stagedStorageIds = (params: JsonObject): string[] => {
42
42
  export const onExpire = async (command: Command, uid: string): Promise<void> => {
43
43
  for (const storageId of stagedStorageIds(command.params)) {
44
44
  try {
45
- await deleteObject(ref(storage, `users/${uid}/uploads/${storageId}`));
45
+ await deleteObject(ref(currentStorage(), `users/${uid}/uploads/${storageId}`));
46
46
  } catch (error) {
47
47
  log.warn(PREFIX, "failed to delete staged upload for expired command; leaving orphan for TTL sweep", { storageId, error: errorMessage(error) });
48
48
  }
@@ -0,0 +1,83 @@
1
+ // Firebase session for this host's remote-host runner, backed by the export/
2
+ // seed-able session controller in `@mulmoclaude/core/remote-host/server` so a
3
+ // server restart doesn't drop the session — the browser parks it in
4
+ // localStorage and hands it back on reconnect (case A', mulmoserver#50).
5
+ //
6
+ // The controller opens a FRESH Firebase app per (re)connect (initializeAuth
7
+ // reads persistence once), so `auth`/`firestore`/`storage` change each time.
8
+ // This module holds the current handles and exposes them as getters, so the
9
+ // runner, onExpire, and attachment ingest always target the live session's
10
+ // Firestore/Storage/uid rather than a stale module-level instance.
11
+ import { createRemoteHostAuth, createRemoteHostSession, isSeedableBlob, type RemoteHostSessionHandles } from "@mulmoclaude/core/remote-host/server";
12
+ import type { Firestore } from "firebase/firestore";
13
+ import type { FirebaseStorage } from "firebase/storage";
14
+
15
+ import { firebaseConfig } from "../../src/config/firebaseConfig.js";
16
+
17
+ const session = createRemoteHostSession(firebaseConfig);
18
+ let handles: RemoteHostSessionHandles | null = null;
19
+
20
+ // A parked blob that Firebase restored to no valid user (expired refresh token,
21
+ // revoked session). Distinct from transient/init failures so the route can
22
+ // answer 401 (client drops the blob) instead of 5xx (client keeps it).
23
+ export class RemoteHostSessionExpiredError extends Error {
24
+ constructor() {
25
+ super("remote-host session could not be restored");
26
+ this.name = "RemoteHostSessionExpiredError";
27
+ }
28
+ }
29
+
30
+ const uidOf = (opened: RemoteHostSessionHandles): string => {
31
+ const uid = opened.auth.currentUser?.uid;
32
+ if (!uid) throw new Error("remote-host session opened without an authenticated user");
33
+ return uid;
34
+ };
35
+
36
+ // Fresh connect: open a clean session and sign in with the browser-minted Google
37
+ // OAuth ID token. The sign-in runs as the session's `validate` step, so a bad
38
+ // token rolls the fresh app back and leaves any live session untouched. Resolves
39
+ // to the authenticated uid.
40
+ export const signIn = async (idToken: string): Promise<string> => {
41
+ const next = await session.open(undefined, async (opened) => {
42
+ await createRemoteHostAuth(opened.auth).signInHost(idToken);
43
+ });
44
+ handles = next;
45
+ return uidOf(next);
46
+ };
47
+
48
+ // Popup-free reconnect: open the session seeded from the browser-parked blob,
49
+ // validating (before any teardown) that it restored a real user. Both a
50
+ // malformed blob and one that yields no user reject with
51
+ // RemoteHostSessionExpiredError — neither can ever restore a session, so the
52
+ // client is told (401) to drop it; genuine transient failures propagate as-is
53
+ // (5xx, blob kept). Reconnect stays non-destructive either way.
54
+ export const restore = async (blob: string): Promise<string> => {
55
+ if (!isSeedableBlob(blob)) throw new RemoteHostSessionExpiredError();
56
+ const next = await session.open(blob, (opened) => (opened.uid ? Promise.resolve() : Promise.reject(new RemoteHostSessionExpiredError())));
57
+ handles = next;
58
+ return uidOf(next);
59
+ };
60
+
61
+ // Tear the local session down even if the Firebase sign-out throws: otherwise
62
+ // `handles`/`currentUid` would stay stale and the app would leak while the route
63
+ // answered 500. The sign-out error still propagates after cleanup.
64
+ export const signOut = async (): Promise<void> => {
65
+ try {
66
+ if (handles) await createRemoteHostAuth(handles.auth).signOutHost();
67
+ } finally {
68
+ handles = null;
69
+ await session.close();
70
+ }
71
+ };
72
+
73
+ const requireHandles = (): RemoteHostSessionHandles => {
74
+ if (!handles) throw new Error("remote-host session is not open");
75
+ return handles;
76
+ };
77
+
78
+ export const currentUid = (): string | null => handles?.auth.currentUser?.uid ?? null;
79
+ export const currentFirestore = (): Firestore => requireHandles().firestore;
80
+ export const currentStorage = (): FirebaseStorage => requireHandles().storage;
81
+
82
+ // The blob the browser parks (refresh token included). Null when disconnected.
83
+ export const exportSession = (): string | null => session.exportSession();
package/src/App.vue CHANGED
@@ -150,6 +150,7 @@
150
150
  ref="chatInputRef"
151
151
  v-model="userInput"
152
152
  v-model:pasted-files="pastedFiles"
153
+ v-model:buffered-messages="currentBufferedMessages"
153
154
  :is-running="activeSessionRunning"
154
155
  :queries="sessionRoleQueries"
155
156
  :session-id="currentSessionId"
@@ -286,6 +287,7 @@
286
287
  ref="chatInputRef"
287
288
  v-model="userInput"
288
289
  v-model:pasted-files="pastedFiles"
290
+ v-model:buffered-messages="currentBufferedMessages"
289
291
  :is-running="activeSessionRunning"
290
292
  :queries="sessionRoleQueries"
291
293
  :session-id="currentSessionId"
@@ -367,6 +369,7 @@ import { resolvePastedAttachment } from "./utils/agent/pastedAttachment";
367
369
  import { applyAgentEvent, type AgentEventContext } from "./utils/agent/eventDispatch";
368
370
  import { pushErrorMessage, beginUserTurn, updateResult, applyToolResultToSession } from "./utils/session/sessionHelpers";
369
371
  import { parseCollectionSlashSeed, makeSyntheticCollectionResult, hasRealCollectionResult } from "./utils/collections/presentSeed";
372
+ import { mergeBufferedIntoDraft } from "./utils/chat/buffer";
370
373
  import { roleName, roleIcon } from "./utils/role/icon";
371
374
  import { createEmptySession } from "./utils/session/sessionFactory";
372
375
  import { buildLoadedSession, parseSessionEntries } from "./utils/session/sessionEntries";
@@ -493,6 +496,18 @@ const { shortcuts } = useShortcuts();
493
496
 
494
497
  const userInput = ref("");
495
498
  const pastedFiles = ref<PastedFile[]>([]);
499
+ // Messages the user sends while a run is in flight queue here instead of
500
+ // dispatching, keyed by the session they belong to so concurrent runs in
501
+ // different sessions never mix. `currentBufferedMessages` is the displayed
502
+ // session's queue; it merges back into `userInput` when that session's run
503
+ // finishes (see watch below).
504
+ const bufferedMessagesBySession = ref<Record<string, string[]>>({});
505
+ const currentBufferedMessages = computed<string[]>({
506
+ get: () => bufferedMessagesBySession.value[currentSessionId.value] ?? [],
507
+ set: (messages) => {
508
+ bufferedMessagesBySession.value = { ...bufferedMessagesBySession.value, [currentSessionId.value]: messages };
509
+ },
510
+ });
496
511
  const activePane = ref<"sidebar" | "main">("sidebar");
497
512
 
498
513
  const { sessions, historyError, fetchSessions, setBookmark, deleteSession: deleteSessionFromHistory } = useSessionHistory();
@@ -1113,8 +1128,16 @@ async function resolveAttachmentPaths(files: PastedFile[]): Promise<AttachmentRe
1113
1128
  }
1114
1129
 
1115
1130
  async function sendMessage(text?: string) {
1116
- const message = typeof text === "string" ? text : userInput.value.trim();
1117
- if (!message || activeSessionRunning.value) return;
1131
+ const fromInput = typeof text !== "string";
1132
+ const message = fromInput ? userInput.value.trim() : text.trim();
1133
+ if (!message) return;
1134
+ // Run in flight: queue instead of dispatching. The input isn't locked,
1135
+ // so the user keeps composing; queued lines come back on completion.
1136
+ if (activeSessionRunning.value) {
1137
+ currentBufferedMessages.value = [...currentBufferedMessages.value, message];
1138
+ if (fromInput) userInput.value = "";
1139
+ return;
1140
+ }
1118
1141
  userInput.value = "";
1119
1142
  const filesSnapshot = [...pastedFiles.value];
1120
1143
  pastedFiles.value = [];
@@ -1149,6 +1172,19 @@ async function sendMessage(text?: string) {
1149
1172
  }
1150
1173
  }
1151
1174
 
1175
+ // Drain the displayed session's queued messages back into the input once
1176
+ // its run finishes. Keyed by `currentSessionId`, so switching to another
1177
+ // session shows (and later drains) that session's own queue — a background
1178
+ // run in a different session never dumps its queue into the wrong input.
1179
+ watch([activeSessionRunning, currentSessionId], () => {
1180
+ if (activeSessionRunning.value) return;
1181
+ const queued = currentBufferedMessages.value;
1182
+ if (queued.length === 0) return;
1183
+ userInput.value = mergeBufferedIntoDraft(queued, userInput.value);
1184
+ currentBufferedMessages.value = [];
1185
+ focusChatInput();
1186
+ });
1187
+
1152
1188
  // Stop the in-flight agent run for the displayed session. The server's
1153
1189
  // /api/agent/cancel aborts the AbortController, kills the Claude
1154
1190
  // subprocess, and publishes `session_finished` — which flips
@@ -34,15 +34,40 @@
34
34
  @remove="removeFileAt(index)"
35
35
  />
36
36
  </div>
37
+ <!-- Messages sent while the agent is running queue here instead of
38
+ going out immediately; they merge back into the input for a
39
+ final edit + send once the run finishes (App.vue owns the
40
+ merge). Each chip is removable so a queued line can be dropped
41
+ before it comes back. -->
42
+ <div v-if="bufferedMessages.length > 0" class="flex flex-col gap-1 mb-1" data-testid="buffered-message-list">
43
+ <div
44
+ v-for="(message, index) in bufferedMessages"
45
+ :key="index"
46
+ class="flex items-center gap-1.5 bg-blue-50 border border-blue-200 rounded px-2 py-1 text-xs text-gray-700"
47
+ data-testid="buffered-message"
48
+ >
49
+ <span class="material-icons text-sm leading-none text-blue-400">schedule</span>
50
+ <span class="flex-1 truncate" :title="message">{{ message }}</span>
51
+ <button
52
+ type="button"
53
+ class="text-gray-400 hover:text-red-600 shrink-0 flex items-center"
54
+ :title="t('chatInput.removeBuffered')"
55
+ :aria-label="t('chatInput.removeBuffered')"
56
+ data-testid="buffered-message-remove"
57
+ @click="removeBufferedAt(index)"
58
+ >
59
+ <span class="material-icons text-sm leading-none">close</span>
60
+ </button>
61
+ </div>
62
+ </div>
37
63
  <div class="flex gap-2" :class="{ 'mt-2': pastedFiles.length > 0 }">
38
64
  <textarea
39
65
  ref="textarea"
40
66
  :value="modelValue"
41
67
  data-testid="user-input"
42
- :placeholder="t('chatInput.placeholder')"
68
+ :placeholder="placeholder"
43
69
  rows="2"
44
- class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 disabled:opacity-50 disabled:cursor-not-allowed resize-none"
45
- :disabled="isRunning"
70
+ class="flex-1 bg-white border border-gray-300 rounded px-3 py-2 text-sm text-gray-900 placeholder-gray-400 resize-none"
46
71
  @input="emit('update:modelValue', ($event.target as HTMLTextAreaElement).value)"
47
72
  @compositionstart="imeEnter.onCompositionStart"
48
73
  @compositionend="imeEnter.onCompositionEnd"
@@ -143,6 +168,10 @@ const props = withDefaults(
143
168
  modelValue: string;
144
169
  pastedFiles: PastedFile[];
145
170
  isRunning: boolean;
171
+ /** Messages queued while the agent runs. Rendered as removable chips
172
+ * above the input; App.vue merges them back into `modelValue` when
173
+ * the run finishes. */
174
+ bufferedMessages?: string[];
146
175
  queries?: string[];
147
176
  /** Currently displayed session id. Voice "armed" state is reset
148
177
  * whenever this changes so leaving a session and coming back
@@ -150,17 +179,27 @@ const props = withDefaults(
150
179
  * persisted). */
151
180
  sessionId?: string;
152
181
  }>(),
153
- { queries: () => [], sessionId: "" },
182
+ { bufferedMessages: () => [], queries: () => [], sessionId: "" },
154
183
  );
155
184
 
156
185
  const emit = defineEmits<{
157
186
  "update:modelValue": [value: string];
158
187
  "update:pastedFiles": [files: PastedFile[]];
188
+ "update:bufferedMessages": [messages: string[]];
159
189
  send: [];
160
190
  stop: [];
161
191
  "suggestion-send": [query: string];
162
192
  }>();
163
193
 
194
+ const placeholder = computed(() => (props.isRunning ? t("chatInput.runningPlaceholder") : t("chatInput.placeholder")));
195
+
196
+ function removeBufferedAt(index: number): void {
197
+ emit(
198
+ "update:bufferedMessages",
199
+ props.bufferedMessages.filter((_, i) => i !== index),
200
+ );
201
+ }
202
+
164
203
  // Local voice input (Mac-only). The mic button is hidden unless the
165
204
  // backend reports voice input ready; transcripts are appended to the
166
205
  // input for review, never auto-sent. See plans/done/feat-voice-input.md.
@@ -422,12 +461,38 @@ watch(slashMenuOpen, (open) => {
422
461
  });
423
462
 
424
463
  function onKeydown(event: KeyboardEvent): void {
464
+ // Ctrl/Cmd+Enter inserts a newline instead of sending. Checked before the
465
+ // slash menu so the chord still works while a skill is highlighted (the
466
+ // menu's Enter handler would otherwise select it). A textarea does not
467
+ // insert on this chord natively, so we splice it in at the caret.
468
+ if (isNewlineChord(event)) {
469
+ event.preventDefault();
470
+ insertNewlineAtCursor();
471
+ return;
472
+ }
425
473
  if (slashMenuOpen.value && handleSlashMenuKeydown(slashMenu, event, { isImeConfirmation: imeEnter.isImeConfirmation, onSelect: selectSlashSkill })) {
426
474
  return;
427
475
  }
428
476
  imeEnter.onKeydown(event);
429
477
  }
430
478
 
479
+ function isNewlineChord(event: KeyboardEvent): boolean {
480
+ return event.key === "Enter" && (event.ctrlKey || event.metaKey) && !imeEnter.isImeConfirmation(event);
481
+ }
482
+
483
+ function insertNewlineAtCursor(): void {
484
+ const field = textarea.value;
485
+ const value = props.modelValue;
486
+ const start = field?.selectionStart ?? value.length;
487
+ const end = field?.selectionEnd ?? start;
488
+ emit("update:modelValue", `${value.slice(0, start)}\n${value.slice(end)}`);
489
+ nextTick(() => {
490
+ const caret = start + 1;
491
+ field?.focus();
492
+ field?.setSelectionRange(caret, caret);
493
+ });
494
+ }
495
+
431
496
  // Selection populates `/<name> ` (trailing space dismisses the menu via the
432
497
  // no-bare-token rule and lets the user type arguments) — it never sends.
433
498
  function selectSlashSkill(skill: SkillSummary): void {
@@ -97,10 +97,37 @@ interface RemoteHostStatus {
97
97
  }
98
98
  interface StatusResponse {
99
99
  status: RemoteHostStatus;
100
+ // The server's Firebase session blob (refresh token included). We park it in
101
+ // localStorage so a server restart can reconnect without a Google popup
102
+ // (case A', mulmoserver#50). Null when disconnected.
103
+ session: string | null;
100
104
  }
101
105
 
102
106
  const { t } = useI18n();
103
107
 
108
+ // Same-machine (localhost) trust model — see mulmoserver#50. Wrapped so a
109
+ // storage-disabled context (private mode) degrades to "no persistence" rather
110
+ // than throwing.
111
+ const SESSION_KEY = "remoteHost.session";
112
+ // Reconnect status that means "this blob is expired/invalid" (vs a transient
113
+ // failure), so only then do we drop the parked session.
114
+ const UNAUTHORIZED = 401;
115
+ const loadStoredSession = (): string | null => {
116
+ try {
117
+ return localStorage.getItem(SESSION_KEY);
118
+ } catch {
119
+ return null;
120
+ }
121
+ };
122
+ const persistSession = (blob: string | null): void => {
123
+ try {
124
+ if (blob) localStorage.setItem(SESSION_KEY, blob);
125
+ else localStorage.removeItem(SESSION_KEY);
126
+ } catch {
127
+ /* storage unavailable — reconnect just won't survive a restart */
128
+ }
129
+ };
130
+
104
131
  // Mobile companion PWA. Shown in the popover as help text; not fetched from
105
132
  // this desktop app, so no runtime env override is needed.
106
133
  const MOBILE_URL = "https://mulmoserver.web.app";
@@ -117,12 +144,33 @@ const refreshStatus = async () => {
117
144
  const res = await apiGet<StatusResponse>(API_ROUTES.remoteHost.status);
118
145
  if (res.ok) {
119
146
  status.value = res.data.status;
147
+ // Keep the parked blob fresh (the refresh token can rotate) — but never
148
+ // clear it on a disconnected status, so an auto-reconnect still has it.
149
+ if (res.data.session) persistSession(res.data.session);
120
150
  error.value = null;
121
151
  } else {
122
152
  error.value = res.error || t("remoteHost.statusFailed");
123
153
  }
124
154
  };
125
155
 
156
+ // On load, if the server is disconnected but we have a parked session, restore
157
+ // it without a popup.
158
+ const tryAutoReconnect = async () => {
159
+ if (status.value.connected) return;
160
+ const blob = loadStoredSession();
161
+ if (!blob) return;
162
+ const res = await apiPost<StatusResponse>(API_ROUTES.remoteHost.reconnect, { session: blob });
163
+ if (res.ok) {
164
+ status.value = res.data.status;
165
+ persistSession(res.data.session);
166
+ } else if (res.status === UNAUTHORIZED) {
167
+ // 401 = the blob is genuinely expired/invalid: drop it so the user just sees
168
+ // the normal Connect button. Transient failures (network status 0, backend
169
+ // 5xx) KEEP the blob so a later retry / restart can still reconnect popup-free.
170
+ persistSession(null);
171
+ }
172
+ };
173
+
126
174
  const toggle = () => {
127
175
  open.value = !open.value;
128
176
  if (open.value) void refreshStatus();
@@ -144,6 +192,7 @@ const onConnect = async () => {
144
192
  return;
145
193
  }
146
194
  status.value = res.data.status;
195
+ persistSession(res.data.session); // park the session for popup-free reconnect after a restart
147
196
  open.value = false; // close the popover after a successful login
148
197
  } catch (err) {
149
198
  error.value = errorMessage(err, t("remoteHost.signInFailed"));
@@ -162,6 +211,7 @@ const onDisconnect = async () => {
162
211
  return;
163
212
  }
164
213
  status.value = res.data.status;
214
+ persistSession(null); // forget the parked session on an explicit disconnect
165
215
  open.value = false; // close the popover after a successful logout
166
216
  } catch (err) {
167
217
  error.value = errorMessage(err, t("remoteHost.disconnectFailed"));
@@ -177,7 +227,9 @@ const onDocumentClick = (event: MouseEvent) => {
177
227
  };
178
228
 
179
229
  onMounted(() => {
180
- void refreshStatus();
230
+ refreshStatus()
231
+ .then(tryAutoReconnect)
232
+ .catch(() => undefined);
181
233
  document.addEventListener("mousedown", onDocumentClick);
182
234
  });
183
235
  onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentClick));
@@ -168,6 +168,7 @@ const HOST_API_ROUTES = {
168
168
  // plans/feat-remote-host-firestore-list-collections.md.
169
169
  remoteHost: {
170
170
  connect: "/api/remote-host/connect",
171
+ reconnect: "/api/remote-host/reconnect",
171
172
  disconnect: "/api/remote-host/disconnect",
172
173
  status: "/api/remote-host/status",
173
174
  },