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/assets/{PluginScopedRoot-BBS0j7lL.js → PluginScopedRoot-BhiOgLTT.js} +1 -1
- package/client/assets/{index-BLM4Fy-7.js → index-C29dYZy5.js} +26 -25
- package/client/assets/index-Ctc1Sbhu.css +2 -0
- package/client/assets/{marp-DsqHCCYu.js → marp-Dx1eipfE.js} +1 -1
- package/client/index.html +3 -3
- package/package.json +5 -5
- package/server/api/routes/remoteHost.ts +46 -13
- package/server/remoteHost/handlers/ingestAttachments.ts +3 -4
- package/server/remoteHost/index.ts +14 -11
- package/server/remoteHost/onExpire.ts +2 -2
- package/server/remoteHost/session.ts +83 -0
- package/src/App.vue +38 -2
- package/src/components/ChatInput.vue +69 -4
- package/src/components/RemoteHostControl.vue +53 -1
- package/src/config/apiRoutes.ts +1 -0
- package/src/lang/de.ts +2 -0
- package/src/lang/en.ts +2 -0
- package/src/lang/es.ts +2 -0
- package/src/lang/fr.ts +2 -0
- package/src/lang/ja.ts +2 -0
- package/src/lang/ko.ts +2 -0
- package/src/lang/pt-BR.ts +2 -0
- package/src/lang/zh.ts +2 -0
- package/src/utils/chat/buffer.ts +11 -0
- package/client/assets/index-fGG7U2mb.css +0 -2
- package/server/remoteHost/auth.ts +0 -12
- package/server/remoteHost/firebase.ts +0 -12
package/client/index.html
CHANGED
|
@@ -43,12 +43,12 @@
|
|
|
43
43
|
}
|
|
44
44
|
}
|
|
45
45
|
</script>
|
|
46
|
-
<script type="module" crossorigin src="/assets/index-
|
|
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-
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
1
|
+
// HTTP routes for the remote-host runner.
|
|
2
2
|
//
|
|
3
|
-
// POST /api/remote-host/connect { idToken } → { status }
|
|
4
|
-
// POST /api/remote-host/
|
|
5
|
-
//
|
|
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
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
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
|
|
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
|
|
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
|
|
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 "../
|
|
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(
|
|
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(
|
|
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")
|
|
11
|
-
//
|
|
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,
|
|
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
|
|
26
|
-
// runner
|
|
27
|
-
// factory's plain-string calls to the host
|
|
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
|
|
31
|
-
|
|
32
|
+
signIn,
|
|
33
|
+
restore,
|
|
34
|
+
signOut,
|
|
32
35
|
currentUid,
|
|
33
|
-
startRunner: (channel, hostHandlers, options) => startHostRunner(
|
|
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 {
|
|
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(
|
|
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
|
|
1117
|
-
|
|
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="
|
|
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
|
|
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
|
-
|
|
230
|
+
refreshStatus()
|
|
231
|
+
.then(tryAutoReconnect)
|
|
232
|
+
.catch(() => undefined);
|
|
181
233
|
document.addEventListener("mousedown", onDocumentClick);
|
|
182
234
|
});
|
|
183
235
|
onBeforeUnmount(() => document.removeEventListener("mousedown", onDocumentClick));
|
package/src/config/apiRoutes.ts
CHANGED
|
@@ -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
|
},
|