mulmoclaude 2.3.0 → 2.4.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.
- package/README.md +1 -1
- package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
- package/client/assets/{index-hiEYYDcg.js → index-CkmW0yWc.js} +396 -396
- package/client/assets/{marp-WnOHAJUy.js → marp-OXXg-yyV.js} +1 -1
- package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
- package/client/index.html +2 -2
- package/package.json +11 -11
- package/server/agent/index.ts +5 -3
- package/server/agent/prompt.ts +6 -3
- package/server/agent/promptWorkspacePaths.ts +12 -0
- package/server/api/auth/bearerAuth.ts +10 -24
- package/server/api/auth/viewToken.ts +5 -12
- package/server/api/routes/files.ts +34 -17
- package/server/build/mcp-server.mjs +604 -607
- package/server/events/pub-sub/handshakeAuth.ts +9 -0
- package/server/events/pub-sub/index.ts +47 -14
- package/server/index.ts +8 -23
- package/server/plugins/dev-watcher.ts +1 -1
- package/server/prompts/system/system.md +1 -2
- package/server/utils/launcher/preflight.d.mts +8 -5
- package/server/utils/launcher/preflight.mjs +34 -8
- package/server/workspace/journal/index.ts +3 -0
- package/server/workspace/journal/memoryExtractor.ts +6 -0
- package/server/workspace/memory/legacy-fold.ts +34 -0
- package/server/workspace/memory/legacy-migration-gate.ts +27 -0
- package/server/workspace/memory/llm-classifier.ts +4 -22
- package/server/workspace/memory/migrate.ts +52 -21
- package/server/workspace/memory/run.ts +32 -28
- package/server/workspace/memory/seed-topic-layout-run.ts +50 -0
- package/server/workspace/memory/seed-topic-layout.ts +34 -0
- package/server/workspace/memory/startup.ts +29 -0
- package/server/workspace/memory/topic-migrate.ts +78 -9
- package/server/workspace/memory/topic-run.ts +14 -9
- package/server/workspace/memory/topic-swap.ts +4 -6
- package/server/workspace/workspace.ts +4 -7
- package/src/App.vue +5 -0
- package/src/components/AuthTokenStaleBanner.vue +27 -0
- package/src/components/BackendOfflineBanner.vue +13 -24
- package/src/components/RemoteHostOfflineBanner.vue +12 -21
- package/src/components/StatusBanner.vue +65 -0
- package/src/composables/usePubSub.ts +32 -0
- package/src/config/systemFileDescriptors.ts +1 -1
- package/src/lang/de.ts +10 -4
- package/src/lang/en.ts +10 -4
- package/src/lang/es.ts +10 -4
- package/src/lang/fr.ts +10 -4
- package/src/lang/ja.ts +11 -4
- package/src/lang/ko.ts +11 -4
- package/src/lang/pt-BR.ts +10 -4
- package/src/lang/zh.ts +11 -4
- package/src/main.ts +1 -1
- package/src/utils/api.ts +45 -22
- package/src/utils/auth/authToken.ts +14 -0
- package/src/utils/auth/authTokenHtml.ts +19 -0
- package/src/utils/auth/authTokenRecovery.ts +73 -0
- package/src/utils/dom/authTokenMeta.ts +3 -1
- package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
- package/server/system/logs/aaa +0 -737
- package/server/system/logs/bb +0 -446
- package/server/workspace/billing-migration.ts +0 -69
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { io, type Socket } from "socket.io-client";
|
|
2
|
+
import { getAuthToken } from "../utils/auth/authToken";
|
|
3
|
+
import { authTokenStale, recoverAuthToken } from "../utils/auth/authTokenRecovery";
|
|
2
4
|
|
|
3
5
|
interface PubSubMessage {
|
|
4
6
|
channel: string;
|
|
@@ -35,6 +37,23 @@ function fireReconnectHandlers(): void {
|
|
|
35
37
|
}
|
|
36
38
|
}
|
|
37
39
|
|
|
40
|
+
/** The server refused the handshake. The token is regenerated at every server
|
|
41
|
+
* start, so this is what a restart looks like from a page that stayed open;
|
|
42
|
+
* index.html carries the new token, and `auth` re-reads it on the next
|
|
43
|
+
* CONNECT. socket.io does not retry a refusal on its own (#3433), so the
|
|
44
|
+
* reconnect is explicit — and only with a token the server has not refused
|
|
45
|
+
* yet, so a second refusal ends with the banner rather than a loop (#3438). */
|
|
46
|
+
async function reconnectWithFreshToken(refusedSocket: Socket): Promise<void> {
|
|
47
|
+
const recovery = await recoverAuthToken();
|
|
48
|
+
// `maybeDisconnect` may have let go of this socket while index.html was fetched.
|
|
49
|
+
if (socket !== refusedSocket) return;
|
|
50
|
+
if (recovery === "installed") {
|
|
51
|
+
refusedSocket.connect();
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
authTokenStale.value = true;
|
|
55
|
+
}
|
|
56
|
+
|
|
38
57
|
function connect(): Socket {
|
|
39
58
|
if (socket) return socket;
|
|
40
59
|
|
|
@@ -42,9 +61,13 @@ function connect(): Socket {
|
|
|
42
61
|
path: "/ws/pubsub",
|
|
43
62
|
// Server refuses long-polling fallback, so fail fast here too if the WS upgrade doesn't go through.
|
|
44
63
|
transports: ["websocket"],
|
|
64
|
+
// The same bearer token every `apiCall` attaches (#3433). A function, so
|
|
65
|
+
// each CONNECT re-reads it instead of re-sending the first value.
|
|
66
|
+
auth: (provide) => provide({ token: getAuthToken() }),
|
|
45
67
|
});
|
|
46
68
|
|
|
47
69
|
sock.on("connect", () => {
|
|
70
|
+
authTokenStale.value = false;
|
|
48
71
|
resendSubscriptions(sock);
|
|
49
72
|
if (hasConnectedOnce) {
|
|
50
73
|
fireReconnectHandlers();
|
|
@@ -53,6 +76,15 @@ function connect(): Socket {
|
|
|
53
76
|
}
|
|
54
77
|
});
|
|
55
78
|
|
|
79
|
+
sock.on("connect_error", (err: Error) => {
|
|
80
|
+
// A transport failure leaves `active` true and socket.io retries it on its
|
|
81
|
+
// own. A refusal from the server middleware destroys the socket (`active`
|
|
82
|
+
// false) and nothing else will retry it.
|
|
83
|
+
if (sock.active) return;
|
|
84
|
+
console.error("[usePubSub] handshake refused by the server:", err.message);
|
|
85
|
+
void reconnectWithFreshToken(sock);
|
|
86
|
+
});
|
|
87
|
+
|
|
56
88
|
sock.on("data", (msg: PubSubMessage) => {
|
|
57
89
|
const cbs = listeners.get(msg.channel);
|
|
58
90
|
if (cbs) {
|
|
@@ -55,7 +55,7 @@ export const SYSTEM_FILE_DESCRIPTORS: readonly Entry[] = [
|
|
|
55
55
|
{
|
|
56
56
|
kind: "exact",
|
|
57
57
|
path: "config/scheduler/overrides.json",
|
|
58
|
-
descriptor: { id: "schedulerOverrides", schemaRef: "server/utils/files/scheduler-io.ts", editPolicy: "agent-managed" },
|
|
58
|
+
descriptor: { id: "schedulerOverrides", schemaRef: "server/utils/files/scheduler-overrides-io.ts", editPolicy: "agent-managed" },
|
|
59
59
|
},
|
|
60
60
|
// ── data/ ──
|
|
61
61
|
{
|
package/src/lang/de.ts
CHANGED
|
@@ -139,6 +139,11 @@ const deMessages = {
|
|
|
139
139
|
body: "Ihr Telefon kann erst nach dem erneuten Verbinden an dieses Gerät senden.",
|
|
140
140
|
reconnect: "Erneut verbinden",
|
|
141
141
|
},
|
|
142
|
+
authTokenStale: {
|
|
143
|
+
title: "Diese Seite muss neu geladen werden",
|
|
144
|
+
body: "Der Server hat das Token dieser Seite abgelehnt, und ein neues konnte nicht abgerufen werden. Laden Sie die Seite neu, um fortzufahren.",
|
|
145
|
+
reload: "Neu laden",
|
|
146
|
+
},
|
|
142
147
|
remoteHost: {
|
|
143
148
|
title: "Remote-Host",
|
|
144
149
|
online: "Remote-Host online",
|
|
@@ -607,8 +612,9 @@ const deMessages = {
|
|
|
607
612
|
"Pro-Aufgabe Zeit-/Intervall-Überschreibungen über dem Systemzeitplan. Der Agent bearbeitet dies, wenn du den Zeitpunkt einer wiederkehrenden Aufgabe änderst.",
|
|
608
613
|
},
|
|
609
614
|
schedulerItems: {
|
|
610
|
-
title: "
|
|
611
|
-
summary:
|
|
615
|
+
title: "Alte Kalendereinträge",
|
|
616
|
+
summary:
|
|
617
|
+
"Einträge aus der entfernten Kalenderansicht. Die App zeigt sie nicht mehr an, und nichts führt sie nach Zeitplan aus; nur die alten /api/scheduler-Aktionen lesen und schreiben diese Datei noch. Geplante Aufgaben liegen in config/scheduler/tasks.json.",
|
|
612
618
|
},
|
|
613
619
|
wikiIndex: {
|
|
614
620
|
title: "Wiki-Index",
|
|
@@ -630,9 +636,9 @@ const deMessages = {
|
|
|
630
636
|
"Formatspezifikation, die der Agent liest, um Wiki-Seiten konsistent zu halten. Empfindlich — eine bestimmte Struktur wird erwartet; bevorzuge Agent-getriebene Bearbeitung.",
|
|
631
637
|
},
|
|
632
638
|
memory: {
|
|
633
|
-
title: "Memory",
|
|
639
|
+
title: "Memory (alte Datei)",
|
|
634
640
|
summary:
|
|
635
|
-
"
|
|
641
|
+
"Das ältere Ein-Datei-Gedächtnis. Nur Arbeitsbereiche im älteren Format lesen es; aktuelle Arbeitsbereiche speichern Fakten als Themendateien unter conversations/memory/<type>/<topic>.md und ignorieren diese Datei, Änderungen hier wirken dort also nicht.",
|
|
636
642
|
},
|
|
637
643
|
summariesIndex: {
|
|
638
644
|
title: "Zusammenfassungs-Index",
|
package/src/lang/en.ts
CHANGED
|
@@ -157,6 +157,11 @@ const enMessages = {
|
|
|
157
157
|
body: "Your phone can't send to this device until you reconnect.",
|
|
158
158
|
reconnect: "Reconnect",
|
|
159
159
|
},
|
|
160
|
+
authTokenStale: {
|
|
161
|
+
title: "This page needs a reload",
|
|
162
|
+
body: "The server refused this page's token and a new one could not be fetched. Reload to continue.",
|
|
163
|
+
reload: "Reload",
|
|
164
|
+
},
|
|
160
165
|
remoteHost: {
|
|
161
166
|
title: "Remote host",
|
|
162
167
|
online: "Remote host online",
|
|
@@ -624,8 +629,9 @@ const enMessages = {
|
|
|
624
629
|
"Per-task time / interval overrides applied on top of the system schedule. The agent edits this when you ask to change a recurring task's timing.",
|
|
625
630
|
},
|
|
626
631
|
schedulerItems: {
|
|
627
|
-
title: "
|
|
628
|
-
summary:
|
|
632
|
+
title: "Legacy calendar items",
|
|
633
|
+
summary:
|
|
634
|
+
"Calendar items from the removed Calendar view. The app no longer shows them and nothing runs them on a schedule; only the legacy /api/scheduler actions still read and write this file. Scheduled tasks live in config/scheduler/tasks.json.",
|
|
629
635
|
},
|
|
630
636
|
wikiIndex: {
|
|
631
637
|
title: "Wiki index",
|
|
@@ -644,9 +650,9 @@ const enMessages = {
|
|
|
644
650
|
summary: "The format spec the agent reads to keep wiki pages consistent. Fragile — the agent expects a specific structure, so prefer agent-driven edits.",
|
|
645
651
|
},
|
|
646
652
|
memory: {
|
|
647
|
-
title: "Memory",
|
|
653
|
+
title: "Memory (legacy file)",
|
|
648
654
|
summary:
|
|
649
|
-
"
|
|
655
|
+
"The older single-file memory. Only workspaces still on the older memory format read it; current workspaces keep facts as topic files under conversations/memory/<type>/<topic>.md and ignore this file, so editing it has no effect there.",
|
|
650
656
|
},
|
|
651
657
|
summariesIndex: {
|
|
652
658
|
title: "Summaries index",
|
package/src/lang/es.ts
CHANGED
|
@@ -142,6 +142,11 @@ const esMessages = {
|
|
|
142
142
|
body: "Tu teléfono no podrá enviar a este dispositivo hasta que vuelvas a conectarte.",
|
|
143
143
|
reconnect: "Reconectar",
|
|
144
144
|
},
|
|
145
|
+
authTokenStale: {
|
|
146
|
+
title: "Esta página necesita recargarse",
|
|
147
|
+
body: "El servidor rechazó el token de esta página y no se pudo obtener uno nuevo. Recarga para continuar.",
|
|
148
|
+
reload: "Recargar",
|
|
149
|
+
},
|
|
145
150
|
remoteHost: {
|
|
146
151
|
title: "Host remoto",
|
|
147
152
|
online: "Host remoto en línea",
|
|
@@ -607,8 +612,9 @@ const esMessages = {
|
|
|
607
612
|
"Sobrescrituras de hora / intervalo por tarea aplicadas sobre el horario del sistema. El agente las edita cuando pides cambiar la franja de una tarea recurrente.",
|
|
608
613
|
},
|
|
609
614
|
schedulerItems: {
|
|
610
|
-
title: "
|
|
611
|
-
summary:
|
|
615
|
+
title: "Elementos de calendario antiguos",
|
|
616
|
+
summary:
|
|
617
|
+
"Elementos de la vista de Calendario eliminada. La app ya no los muestra y nada los ejecuta según una programación; solo las acciones antiguas de /api/scheduler siguen leyendo y escribiendo este archivo. Las tareas programadas están en config/scheduler/tasks.json.",
|
|
612
618
|
},
|
|
613
619
|
wikiIndex: {
|
|
614
620
|
title: "Índice de la wiki",
|
|
@@ -628,9 +634,9 @@ const esMessages = {
|
|
|
628
634
|
"Especificación de formato que el agente lee para mantener las páginas consistentes. Frágil — espera una estructura concreta; prefiere ediciones del agente.",
|
|
629
635
|
},
|
|
630
636
|
memory: {
|
|
631
|
-
title: "Memoria",
|
|
637
|
+
title: "Memoria (archivo antiguo)",
|
|
632
638
|
summary:
|
|
633
|
-
"
|
|
639
|
+
"La memoria antigua de un solo archivo. Solo la leen los espacios de trabajo con el formato antiguo; los actuales guardan los hechos como archivos de tema en conversations/memory/<type>/<topic>.md e ignoran este archivo, así que editarlo no tiene efecto en ellos.",
|
|
634
640
|
},
|
|
635
641
|
summariesIndex: {
|
|
636
642
|
title: "Índice de resúmenes",
|
package/src/lang/fr.ts
CHANGED
|
@@ -137,6 +137,11 @@ const frMessages = {
|
|
|
137
137
|
body: "Votre téléphone ne pourra pas envoyer vers cet appareil tant que vous n'êtes pas reconnecté.",
|
|
138
138
|
reconnect: "Se reconnecter",
|
|
139
139
|
},
|
|
140
|
+
authTokenStale: {
|
|
141
|
+
title: "Cette page doit être rechargée",
|
|
142
|
+
body: "Le serveur a refusé le jeton de cette page et un nouveau n'a pas pu être récupéré. Rechargez pour continuer.",
|
|
143
|
+
reload: "Recharger",
|
|
144
|
+
},
|
|
140
145
|
remoteHost: {
|
|
141
146
|
title: "Hôte distant",
|
|
142
147
|
online: "Hôte distant en ligne",
|
|
@@ -602,8 +607,9 @@ const frMessages = {
|
|
|
602
607
|
"Surcharges horaires / d'intervalle par tâche appliquées au-dessus du planning système. L'agent édite ce fichier quand vous demandez à changer l'horaire d'une tâche récurrente.",
|
|
603
608
|
},
|
|
604
609
|
schedulerItems: {
|
|
605
|
-
title: "
|
|
606
|
-
summary:
|
|
610
|
+
title: "Anciens éléments de calendrier",
|
|
611
|
+
summary:
|
|
612
|
+
"Éléments de la vue Calendrier supprimée. L'application ne les affiche plus et rien ne les exécute selon un planning ; seules les anciennes actions /api/scheduler lisent et écrivent encore ce fichier. Les tâches planifiées se trouvent dans config/scheduler/tasks.json.",
|
|
607
613
|
},
|
|
608
614
|
wikiIndex: {
|
|
609
615
|
title: "Index du wiki",
|
|
@@ -623,9 +629,9 @@ const frMessages = {
|
|
|
623
629
|
"Spécification de format que l'agent lit pour garder les pages du wiki cohérentes. Fragile — il attend une structure précise ; préférez les éditions via l'agent.",
|
|
624
630
|
},
|
|
625
631
|
memory: {
|
|
626
|
-
title: "Mémoire",
|
|
632
|
+
title: "Mémoire (ancien fichier)",
|
|
627
633
|
summary:
|
|
628
|
-
"
|
|
634
|
+
"L'ancienne mémoire en un seul fichier. Seuls les espaces de travail encore à l'ancien format la lisent ; les espaces actuels stockent les faits dans des fichiers de sujet sous conversations/memory/<type>/<topic>.md et ignorent ce fichier, donc le modifier n'a pas d'effet pour eux.",
|
|
629
635
|
},
|
|
630
636
|
summariesIndex: {
|
|
631
637
|
title: "Index des résumés",
|
package/src/lang/ja.ts
CHANGED
|
@@ -142,6 +142,11 @@ const jaMessages = {
|
|
|
142
142
|
body: "再接続するまで、スマホからの送信はこの端末に届きません。",
|
|
143
143
|
reconnect: "再接続",
|
|
144
144
|
},
|
|
145
|
+
authTokenStale: {
|
|
146
|
+
title: "ページの再読み込みが必要です",
|
|
147
|
+
body: "サーバーがこのページのトークンを拒否し、新しいトークンを取得できませんでした。続けるには再読み込みしてください。",
|
|
148
|
+
reload: "再読み込み",
|
|
149
|
+
},
|
|
145
150
|
remoteHost: {
|
|
146
151
|
title: "リモートホスト",
|
|
147
152
|
online: "リモートホスト: オンライン",
|
|
@@ -598,8 +603,9 @@ const jaMessages = {
|
|
|
598
603
|
summary: "システム既定スケジュールに上書きするタスクごとの時刻 / 間隔の上書き。会話で「このタスクの時刻を変えて」と頼むとエージェントが書き換えます。",
|
|
599
604
|
},
|
|
600
605
|
schedulerItems: {
|
|
601
|
-
title: "
|
|
602
|
-
summary:
|
|
606
|
+
title: "旧カレンダー項目",
|
|
607
|
+
summary:
|
|
608
|
+
"削除済みのカレンダー画面の項目。アプリには表示されず、予定どおりに実行されることもありません。このファイルを読み書きするのは旧来の /api/scheduler の操作だけです。予定タスクは config/scheduler/tasks.json にあります。",
|
|
603
609
|
},
|
|
604
610
|
wikiIndex: {
|
|
605
611
|
title: "Wiki インデックス",
|
|
@@ -618,8 +624,9 @@ const jaMessages = {
|
|
|
618
624
|
summary: "Wiki ページの一貫性を保つためにエージェントが参照する書式仕様。壊れやすい — 特定の構造を期待するため、エージェント主導の編集を推奨します。",
|
|
619
625
|
},
|
|
620
626
|
memory: {
|
|
621
|
-
title: "
|
|
622
|
-
summary:
|
|
627
|
+
title: "メモリ(旧ファイル)",
|
|
628
|
+
summary:
|
|
629
|
+
"以前の 1 ファイル形式のメモリ。旧形式のワークスペースだけが読み込みます。現在のワークスペースは事実を conversations/memory/<type>/<topic>.md の topic ファイルに保存し、このファイルは読まないため、編集しても反映されません。",
|
|
623
630
|
},
|
|
624
631
|
summariesIndex: {
|
|
625
632
|
title: "サマリインデックス",
|
package/src/lang/ko.ts
CHANGED
|
@@ -143,6 +143,11 @@ const koMessages = {
|
|
|
143
143
|
body: "다시 연결하기 전에는 휴대폰에서 이 기기로 보낼 수 없습니다.",
|
|
144
144
|
reconnect: "다시 연결",
|
|
145
145
|
},
|
|
146
|
+
authTokenStale: {
|
|
147
|
+
title: "페이지를 새로 고쳐야 합니다",
|
|
148
|
+
body: "서버가 이 페이지의 토큰을 거부했고 새 토큰을 가져오지 못했습니다. 계속하려면 새로 고침하세요.",
|
|
149
|
+
reload: "새로 고침",
|
|
150
|
+
},
|
|
146
151
|
remoteHost: {
|
|
147
152
|
title: "원격 호스트",
|
|
148
153
|
online: "원격 호스트 온라인",
|
|
@@ -596,8 +601,9 @@ const koMessages = {
|
|
|
596
601
|
summary: "시스템 기본 일정 위에 덮어쓰는 작업별 시간 / 간격 오버라이드. 반복 작업 시간을 변경해 달라고 요청하면 에이전트가 여기에 기록합니다.",
|
|
597
602
|
},
|
|
598
603
|
schedulerItems: {
|
|
599
|
-
title: "
|
|
600
|
-
summary:
|
|
604
|
+
title: "이전 캘린더 항목",
|
|
605
|
+
summary:
|
|
606
|
+
"삭제된 캘린더 화면의 항목입니다. 앱에 표시되지 않으며 일정에 따라 실행되지도 않습니다. 이 파일을 읽고 쓰는 것은 이전 /api/scheduler 작업뿐입니다. 예약 작업은 config/scheduler/tasks.json에 있습니다.",
|
|
601
607
|
},
|
|
602
608
|
wikiIndex: {
|
|
603
609
|
title: "위키 인덱스",
|
|
@@ -616,8 +622,9 @@ const koMessages = {
|
|
|
616
622
|
summary: "에이전트가 위키 페이지 일관성을 유지하기 위해 참조하는 형식 명세. 취약 — 특정 구조를 기대하므로 에이전트 주도 편집을 권장합니다.",
|
|
617
623
|
},
|
|
618
624
|
memory: {
|
|
619
|
-
title: "메모리",
|
|
620
|
-
summary:
|
|
625
|
+
title: "메모리(이전 파일)",
|
|
626
|
+
summary:
|
|
627
|
+
"예전의 단일 파일 메모리입니다. 이전 형식의 워크스페이스만 읽습니다. 현재 워크스페이스는 사실을 conversations/memory/<type>/<topic>.md 토픽 파일에 저장하고 이 파일은 읽지 않으므로, 편집해도 반영되지 않습니다.",
|
|
621
628
|
},
|
|
622
629
|
summariesIndex: {
|
|
623
630
|
title: "요약 인덱스",
|
package/src/lang/pt-BR.ts
CHANGED
|
@@ -137,6 +137,11 @@ const ptBRMessages = {
|
|
|
137
137
|
body: "Seu telefone não conseguirá enviar para este dispositivo até você reconectar.",
|
|
138
138
|
reconnect: "Reconectar",
|
|
139
139
|
},
|
|
140
|
+
authTokenStale: {
|
|
141
|
+
title: "Esta página precisa ser recarregada",
|
|
142
|
+
body: "O servidor recusou o token desta página e não foi possível obter um novo. Recarregue para continuar.",
|
|
143
|
+
reload: "Recarregar",
|
|
144
|
+
},
|
|
140
145
|
remoteHost: {
|
|
141
146
|
title: "Host remoto",
|
|
142
147
|
online: "Host remoto on-line",
|
|
@@ -597,8 +602,9 @@ const ptBRMessages = {
|
|
|
597
602
|
"Sobrescritas por tarefa de horário / intervalo aplicadas sobre o agendamento do sistema. O agente edita quando você pede para mudar o horário de uma tarefa.",
|
|
598
603
|
},
|
|
599
604
|
schedulerItems: {
|
|
600
|
-
title: "
|
|
601
|
-
summary:
|
|
605
|
+
title: "Itens de calendário antigos",
|
|
606
|
+
summary:
|
|
607
|
+
"Itens da visualização de Calendário removida. O app não os mostra mais e nada os executa por agendamento; só as ações antigas de /api/scheduler ainda leem e gravam este arquivo. As tarefas agendadas ficam em config/scheduler/tasks.json.",
|
|
602
608
|
},
|
|
603
609
|
wikiIndex: {
|
|
604
610
|
title: "Índice da wiki",
|
|
@@ -618,9 +624,9 @@ const ptBRMessages = {
|
|
|
618
624
|
"Especificação de formato que o agente lê para manter as páginas da wiki consistentes. Frágil — espera uma estrutura específica; prefira edições via agente.",
|
|
619
625
|
},
|
|
620
626
|
memory: {
|
|
621
|
-
title: "Memória",
|
|
627
|
+
title: "Memória (arquivo antigo)",
|
|
622
628
|
summary:
|
|
623
|
-
"
|
|
629
|
+
"A memória antiga em um único arquivo. Só os espaços de trabalho no formato antigo a leem; os atuais guardam os fatos como arquivos de tópico em conversations/memory/<type>/<topic>.md e ignoram este arquivo, então editá-lo não tem efeito neles.",
|
|
624
630
|
},
|
|
625
631
|
summariesIndex: {
|
|
626
632
|
title: "Índice de resumos",
|
package/src/lang/zh.ts
CHANGED
|
@@ -138,6 +138,11 @@ const zhMessages = {
|
|
|
138
138
|
body: "重新连接之前,手机将无法发送到此设备。",
|
|
139
139
|
reconnect: "重新连接",
|
|
140
140
|
},
|
|
141
|
+
authTokenStale: {
|
|
142
|
+
title: "此页面需要重新加载",
|
|
143
|
+
body: "服务器拒绝了此页面的令牌,且无法获取新令牌。请重新加载以继续。",
|
|
144
|
+
reload: "重新加载",
|
|
145
|
+
},
|
|
141
146
|
remoteHost: {
|
|
142
147
|
title: "远程主机",
|
|
143
148
|
online: "远程主机在线",
|
|
@@ -584,8 +589,9 @@ const zhMessages = {
|
|
|
584
589
|
summary: "在系统调度之上叠加的每任务时间 / 间隔覆盖。当你要求修改某个定期任务的时间时,代理会写入此文件。",
|
|
585
590
|
},
|
|
586
591
|
schedulerItems: {
|
|
587
|
-
title: "
|
|
588
|
-
summary:
|
|
592
|
+
title: "旧日历条目",
|
|
593
|
+
summary:
|
|
594
|
+
"已删除的日历视图中的条目。应用不再显示它们,也不会按计划执行它们;只有旧的 /api/scheduler 操作仍会读写此文件。计划任务位于 config/scheduler/tasks.json。",
|
|
589
595
|
},
|
|
590
596
|
wikiIndex: {
|
|
591
597
|
title: "Wiki 索引",
|
|
@@ -604,8 +610,9 @@ const zhMessages = {
|
|
|
604
610
|
summary: "代理用于保持 Wiki 页面一致性的格式规范。脆弱 — 代理期望特定结构,建议交由代理编辑。",
|
|
605
611
|
},
|
|
606
612
|
memory: {
|
|
607
|
-
title: "
|
|
608
|
-
summary:
|
|
613
|
+
title: "记忆(旧文件)",
|
|
614
|
+
summary:
|
|
615
|
+
"旧的单文件记忆。只有仍使用旧格式的工作区会读取它;当前工作区把事实保存为 conversations/memory/<type>/<topic>.md 下的主题文件,并忽略此文件,因此编辑它不会生效。",
|
|
609
616
|
},
|
|
610
617
|
summariesIndex: {
|
|
611
618
|
title: "总结索引",
|
package/src/main.ts
CHANGED
|
@@ -9,7 +9,7 @@ import App from "./App.vue";
|
|
|
9
9
|
import router from "./router/index";
|
|
10
10
|
import { installGuards } from "./router/guards";
|
|
11
11
|
import i18n from "./lib/vue-i18n";
|
|
12
|
-
import { setAuthToken } from "./utils/
|
|
12
|
+
import { setAuthToken } from "./utils/auth/authToken";
|
|
13
13
|
import { readAuthTokenFromMeta } from "./utils/dom/authTokenMeta";
|
|
14
14
|
import { loadRuntimePlugins } from "./tools/runtimeLoader";
|
|
15
15
|
import { startDevPluginReloadListener } from "./composables/useDevPluginReload";
|
package/src/utils/api.ts
CHANGED
|
@@ -25,11 +25,13 @@
|
|
|
25
25
|
// `!res.ok` branches. Network errors and HTTP errors surface through the
|
|
26
26
|
// same `{ ok: false }` shape.
|
|
27
27
|
//
|
|
28
|
-
//
|
|
29
|
-
//
|
|
30
|
-
//
|
|
28
|
+
// The bearer token lives in `./auth/authToken` (set once at bootstrap), and
|
|
29
|
+
// `./auth/authTokenRecovery` replaces it after a server restart (#3438).
|
|
30
|
+
// Interceptors for logging, retry policy or metrics would go here.
|
|
31
31
|
|
|
32
32
|
import { ref, type Ref } from "vue";
|
|
33
|
+
import { getAuthToken } from "./auth/authToken";
|
|
34
|
+
import { authTokenStale, recoverAuthToken } from "./auth/authTokenRecovery";
|
|
33
35
|
import { errorMessage } from "./errors";
|
|
34
36
|
import { hasStringProp } from "./types";
|
|
35
37
|
|
|
@@ -89,19 +91,6 @@ function isAbortError(err: unknown): boolean {
|
|
|
89
91
|
return hasStringProp(err, "name") && err.name === "AbortError";
|
|
90
92
|
}
|
|
91
93
|
|
|
92
|
-
// ── Auth token (populated by bootstrap; consumed by every call) ─────
|
|
93
|
-
|
|
94
|
-
let authToken: string | null = null;
|
|
95
|
-
|
|
96
|
-
/**
|
|
97
|
-
* Set the bearer token used on every API call. Call once during app
|
|
98
|
-
* bootstrap, typically after reading a `<meta>` tag or window global
|
|
99
|
-
* populated by the server.
|
|
100
|
-
*/
|
|
101
|
-
export function setAuthToken(token: string | null): void {
|
|
102
|
-
authToken = token;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
94
|
// ── Types ────────────────────────────────────────────────────────────
|
|
106
95
|
|
|
107
96
|
export type ApiResult<T> = { ok: true; data: T } | { ok: false; error: string; status: number };
|
|
@@ -145,6 +134,7 @@ function buildHeaders(opts: { headers?: Record<string, string> | undefined }, ha
|
|
|
145
134
|
if (hasBody && headers["Content-Type"] === undefined) {
|
|
146
135
|
headers["Content-Type"] = "application/json";
|
|
147
136
|
}
|
|
137
|
+
const authToken = getAuthToken();
|
|
148
138
|
if (authToken && headers["Authorization"] === undefined) {
|
|
149
139
|
headers["Authorization"] = `Bearer ${authToken}`;
|
|
150
140
|
}
|
|
@@ -275,11 +265,33 @@ async function successResult<T>(res: Response): Promise<ApiResult<T>> {
|
|
|
275
265
|
}
|
|
276
266
|
}
|
|
277
267
|
|
|
268
|
+
const UNAUTHORIZED_STATUS = 401;
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Issue `request`, and once more after a 401. A 401 is what a server restart
|
|
272
|
+
* looks like from a page that stayed open: the token was regenerated and this
|
|
273
|
+
* page still presents the old one (#3438). index.html carries the new one, so
|
|
274
|
+
* the token is fetched again and the request re-issued — only when a
|
|
275
|
+
* DIFFERENT token was installed; `request` builds its headers per call, so
|
|
276
|
+
* the second issue presents it. A 401 the server sends for its own reasons
|
|
277
|
+
* (`/api/remote-host/reconnect` on an expired blob) leaves the token
|
|
278
|
+
* unchanged and reaches the caller as is, and a token the server refuses
|
|
279
|
+
* twice is reported through `authTokenStale` rather than retried again.
|
|
280
|
+
*/
|
|
281
|
+
async function withTokenRecovery(request: () => Promise<Response>): Promise<Response> {
|
|
282
|
+
const first = await request();
|
|
283
|
+
if (first.status !== UNAUTHORIZED_STATUS) return first;
|
|
284
|
+
if ((await recoverAuthToken()) !== "installed") return first;
|
|
285
|
+
const retried = await request();
|
|
286
|
+
if (retried.status === UNAUTHORIZED_STATUS) authTokenStale.value = true;
|
|
287
|
+
return retried;
|
|
288
|
+
}
|
|
289
|
+
|
|
278
290
|
export async function apiCall<T = unknown>(path: string, opts: ApiOptions = {}): Promise<ApiResult<T>> {
|
|
279
291
|
const url = `${path}${buildQueryString(opts.query)}`;
|
|
280
292
|
let res: Response;
|
|
281
293
|
try {
|
|
282
|
-
res = await fetch(url, buildInit(opts));
|
|
294
|
+
res = await withTokenRecovery(() => fetch(url, buildInit(opts)));
|
|
283
295
|
} catch (err) {
|
|
284
296
|
return transportFailure<T>(err);
|
|
285
297
|
}
|
|
@@ -321,17 +333,28 @@ export interface RawOptions {
|
|
|
321
333
|
/**
|
|
322
334
|
* Escape hatch for endpoints returning binary / streaming / non-JSON
|
|
323
335
|
* bodies (PDF download, audio blob, SSE, etc.). Auth header is still
|
|
324
|
-
* applied
|
|
336
|
+
* applied, and a 401 is retried once with a recovered token the same way
|
|
337
|
+
* `apiCall` does it; other handling is the caller's responsibility.
|
|
325
338
|
*
|
|
326
339
|
* Throws on network errors. Does NOT check `res.ok`.
|
|
327
340
|
*/
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
const init: FetchInit = {
|
|
341
|
+
function buildRawInit(opts: RawOptions): FetchInit {
|
|
342
|
+
return {
|
|
331
343
|
method: opts.method ?? "GET",
|
|
332
344
|
headers: buildHeaders(opts, false),
|
|
333
345
|
...(opts.body !== undefined ? { body: opts.body } : {}),
|
|
334
346
|
...(opts.signal !== undefined ? { signal: opts.signal } : {}),
|
|
335
347
|
};
|
|
336
|
-
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
/** A stream is consumed by the first issue, so a request carrying one
|
|
351
|
+
* cannot be re-issued after a 401; every other body fetch accepts can. */
|
|
352
|
+
function isReplayableBody(body: RawOptions["body"]): boolean {
|
|
353
|
+
return !(typeof ReadableStream !== "undefined" && body instanceof ReadableStream);
|
|
354
|
+
}
|
|
355
|
+
|
|
356
|
+
export async function apiFetchRaw(path: string, opts: RawOptions = {}): Promise<Response> {
|
|
357
|
+
const url = `${path}${buildQueryString(opts.query)}`;
|
|
358
|
+
const request = (): Promise<Response> => fetch(url, buildRawInit(opts));
|
|
359
|
+
return isReplayableBody(opts.body) ? withTokenRecovery(request) : request();
|
|
337
360
|
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
// The bearer token every call presents, read once from index.html at
|
|
2
|
+
// bootstrap (`src/main.ts`) and replaced after a server restart
|
|
3
|
+
// (`authTokenRecovery.ts`). A module so that `api.ts`, the `/ws/pubsub`
|
|
4
|
+
// handshake and the recovery read one value.
|
|
5
|
+
|
|
6
|
+
let authToken: string | null = null;
|
|
7
|
+
|
|
8
|
+
export function setAuthToken(token: string | null): void {
|
|
9
|
+
authToken = token;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export function getAuthToken(): string | null {
|
|
13
|
+
return authToken;
|
|
14
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// The served index.html carries the bearer token in
|
|
2
|
+
// `<meta name="mulmoclaude-auth" content="…">`: the production handler and the
|
|
3
|
+
// Vite dev plugin substitute it on every request. Pure string work, so the
|
|
4
|
+
// page can read a token out of a re-fetched index.html without a DOM.
|
|
5
|
+
|
|
6
|
+
export const AUTH_TOKEN_META_NAME = "mulmoclaude-auth";
|
|
7
|
+
|
|
8
|
+
const META_TAGS = /<meta\b[^>]*>/gi;
|
|
9
|
+
const AUTH_META_NAME_ATTRIBUTE = new RegExp(`\\bname\\s*=\\s*"${AUTH_TOKEN_META_NAME}"`, "i");
|
|
10
|
+
const CONTENT_ATTRIBUTE = /\bcontent\s*=\s*"([^"]*)"/i;
|
|
11
|
+
|
|
12
|
+
/** The token `html` carries, `null` when the tag is missing or its content is
|
|
13
|
+
* empty — the same two cases `readAuthTokenFromMeta` reads as "no token". */
|
|
14
|
+
export function extractAuthTokenFromHtml(html: string): string | null {
|
|
15
|
+
const authMetaTag = (html.match(META_TAGS) ?? []).find((tag) => AUTH_META_NAME_ATTRIBUTE.test(tag));
|
|
16
|
+
if (authMetaTag === undefined) return null;
|
|
17
|
+
const content = CONTENT_ATTRIBUTE.exec(authMetaTag)?.[1];
|
|
18
|
+
return content === undefined || content === "" ? null : content;
|
|
19
|
+
}
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
// After a server restart an open page holds a token the server no longer
|
|
2
|
+
// accepts: the token is regenerated at every start, and the page read it once
|
|
3
|
+
// from index.html (#3438). index.html is served without a token and carries
|
|
4
|
+
// the current one on every request — the production handler substitutes it
|
|
5
|
+
// per request, the Vite plugin does the same for a loopback requester — so the
|
|
6
|
+
// page fetches it again instead of asking the user to reload.
|
|
7
|
+
|
|
8
|
+
import { ref, type Ref } from "vue";
|
|
9
|
+
import { errorMessage } from "../errors";
|
|
10
|
+
import { getAuthToken, setAuthToken } from "./authToken";
|
|
11
|
+
import { extractAuthTokenFromHtml } from "./authTokenHtml";
|
|
12
|
+
|
|
13
|
+
/** How a recovery ended. `installed`: index.html carried a token other than
|
|
14
|
+
* the one the page held, and every later call presents it. `unchanged`: it
|
|
15
|
+
* carried the token the server just refused. `unavailable`: it carried none,
|
|
16
|
+
* or was not served. `unreachable`: the fetch itself failed. */
|
|
17
|
+
export type AuthTokenRecovery = "installed" | "unchanged" | "unavailable" | "unreachable";
|
|
18
|
+
|
|
19
|
+
/** True once the server refused this page's token and a fresh one could not
|
|
20
|
+
* be fetched. `AuthTokenStaleBanner` offers a reload while it is set. */
|
|
21
|
+
export const authTokenStale: Ref<boolean> = ref(false);
|
|
22
|
+
|
|
23
|
+
const INDEX_HTML_PATH = "/";
|
|
24
|
+
const INDEX_HTML_FETCH_TIMEOUT_MS = 10_000;
|
|
25
|
+
|
|
26
|
+
/** What a fetched token means for a page holding `held`. */
|
|
27
|
+
export function recoveryOutcome(held: string | null, fetched: string | null): Exclude<AuthTokenRecovery, "unreachable"> {
|
|
28
|
+
if (fetched === null) return "unavailable";
|
|
29
|
+
return fetched === held ? "unchanged" : "installed";
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** The served index.html, `null` when it could not be fetched at all. A reply
|
|
33
|
+
* other than 2xx is a server that answered without a token, not an outage. */
|
|
34
|
+
async function fetchIndexHtml(): Promise<string | null> {
|
|
35
|
+
const timeoutController = new AbortController();
|
|
36
|
+
const timeoutTimer = setTimeout(() => timeoutController.abort(), INDEX_HTML_FETCH_TIMEOUT_MS);
|
|
37
|
+
try {
|
|
38
|
+
const indexHtmlResponse = await fetch(INDEX_HTML_PATH, { cache: "no-store", signal: timeoutController.signal });
|
|
39
|
+
return indexHtmlResponse.ok ? await indexHtmlResponse.text() : "";
|
|
40
|
+
} catch (err) {
|
|
41
|
+
console.warn(`[authTokenRecovery] could not fetch ${INDEX_HTML_PATH}:`, errorMessage(err));
|
|
42
|
+
return null;
|
|
43
|
+
} finally {
|
|
44
|
+
clearTimeout(timeoutTimer);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function runRecovery(): Promise<AuthTokenRecovery> {
|
|
49
|
+
const html = await fetchIndexHtml();
|
|
50
|
+
if (html === null) return "unreachable";
|
|
51
|
+
const fetchedToken = extractAuthTokenFromHtml(html);
|
|
52
|
+
const outcome = recoveryOutcome(getAuthToken(), fetchedToken);
|
|
53
|
+
if (outcome === "installed") {
|
|
54
|
+
setAuthToken(fetchedToken);
|
|
55
|
+
authTokenStale.value = false;
|
|
56
|
+
} else if (outcome === "unavailable") {
|
|
57
|
+
authTokenStale.value = true;
|
|
58
|
+
}
|
|
59
|
+
return outcome;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
let inFlightRecovery: Promise<AuthTokenRecovery> | null = null;
|
|
63
|
+
|
|
64
|
+
/** Fetch index.html again and install the token it carries. Concurrent
|
|
65
|
+
* callers — every 401 of a burst, plus the socket refusal — share one fetch. */
|
|
66
|
+
export function recoverAuthToken(): Promise<AuthTokenRecovery> {
|
|
67
|
+
if (inFlightRecovery === null) {
|
|
68
|
+
inFlightRecovery = runRecovery().finally(() => {
|
|
69
|
+
inFlightRecovery = null;
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return inFlightRecovery;
|
|
73
|
+
}
|
|
@@ -11,8 +11,10 @@
|
|
|
11
11
|
// available; every subsequent API call will 401, which is the
|
|
12
12
|
// correct dev-time signal)
|
|
13
13
|
|
|
14
|
+
import { AUTH_TOKEN_META_NAME } from "../auth/authTokenHtml";
|
|
15
|
+
|
|
14
16
|
export function readAuthTokenFromMeta(): string | null {
|
|
15
|
-
const meta = document.querySelector(
|
|
17
|
+
const meta = document.querySelector(`meta[name="${AUTH_TOKEN_META_NAME}"]`);
|
|
16
18
|
if (meta === null) return null;
|
|
17
19
|
const content = meta.getAttribute("content");
|
|
18
20
|
if (content === null || content === "") return null;
|