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.
Files changed (60) hide show
  1. package/README.md +1 -1
  2. package/client/assets/{JsonEditor-CU5Q6RZZ.js → JsonEditor-ZvNJuqs_.js} +1 -1
  3. package/client/assets/{index-hiEYYDcg.js → index-CkmW0yWc.js} +396 -396
  4. package/client/assets/{marp-WnOHAJUy.js → marp-OXXg-yyV.js} +1 -1
  5. package/client/assets/vue-i18n-BbRMUmOW.js +3 -0
  6. package/client/index.html +2 -2
  7. package/package.json +11 -11
  8. package/server/agent/index.ts +5 -3
  9. package/server/agent/prompt.ts +6 -3
  10. package/server/agent/promptWorkspacePaths.ts +12 -0
  11. package/server/api/auth/bearerAuth.ts +10 -24
  12. package/server/api/auth/viewToken.ts +5 -12
  13. package/server/api/routes/files.ts +34 -17
  14. package/server/build/mcp-server.mjs +604 -607
  15. package/server/events/pub-sub/handshakeAuth.ts +9 -0
  16. package/server/events/pub-sub/index.ts +47 -14
  17. package/server/index.ts +8 -23
  18. package/server/plugins/dev-watcher.ts +1 -1
  19. package/server/prompts/system/system.md +1 -2
  20. package/server/utils/launcher/preflight.d.mts +8 -5
  21. package/server/utils/launcher/preflight.mjs +34 -8
  22. package/server/workspace/journal/index.ts +3 -0
  23. package/server/workspace/journal/memoryExtractor.ts +6 -0
  24. package/server/workspace/memory/legacy-fold.ts +34 -0
  25. package/server/workspace/memory/legacy-migration-gate.ts +27 -0
  26. package/server/workspace/memory/llm-classifier.ts +4 -22
  27. package/server/workspace/memory/migrate.ts +52 -21
  28. package/server/workspace/memory/run.ts +32 -28
  29. package/server/workspace/memory/seed-topic-layout-run.ts +50 -0
  30. package/server/workspace/memory/seed-topic-layout.ts +34 -0
  31. package/server/workspace/memory/startup.ts +29 -0
  32. package/server/workspace/memory/topic-migrate.ts +78 -9
  33. package/server/workspace/memory/topic-run.ts +14 -9
  34. package/server/workspace/memory/topic-swap.ts +4 -6
  35. package/server/workspace/workspace.ts +4 -7
  36. package/src/App.vue +5 -0
  37. package/src/components/AuthTokenStaleBanner.vue +27 -0
  38. package/src/components/BackendOfflineBanner.vue +13 -24
  39. package/src/components/RemoteHostOfflineBanner.vue +12 -21
  40. package/src/components/StatusBanner.vue +65 -0
  41. package/src/composables/usePubSub.ts +32 -0
  42. package/src/config/systemFileDescriptors.ts +1 -1
  43. package/src/lang/de.ts +10 -4
  44. package/src/lang/en.ts +10 -4
  45. package/src/lang/es.ts +10 -4
  46. package/src/lang/fr.ts +10 -4
  47. package/src/lang/ja.ts +11 -4
  48. package/src/lang/ko.ts +11 -4
  49. package/src/lang/pt-BR.ts +10 -4
  50. package/src/lang/zh.ts +11 -4
  51. package/src/main.ts +1 -1
  52. package/src/utils/api.ts +45 -22
  53. package/src/utils/auth/authToken.ts +14 -0
  54. package/src/utils/auth/authTokenHtml.ts +19 -0
  55. package/src/utils/auth/authTokenRecovery.ts +73 -0
  56. package/src/utils/dom/authTokenMeta.ts +3 -1
  57. package/client/assets/vue-i18n-CdSW1CeF.js +0 -3
  58. package/server/system/logs/aaa +0 -737
  59. package/server/system/logs/bb +0 -446
  60. 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: "Scheduler-Item-Warteschlange",
611
- summary: "Geplante Aufrufe, die zur Auslösung bereitstehen. Agent-verwaltet; nicht von Hand bearbeiten, ohne jedes Feld zu verstehen.",
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
- "Destillierte Fakten über dich, immer als Kontext für neue Gespräche geladen. Der Journal-Extraktor hängt automatisch an; manuelle Bearbeitung ebenfalls möglich.",
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: "Scheduler items queue",
628
- summary: "Active scheduled invocations ready to fire. Agent-managed; do not hand-edit unless you know exactly what each field means.",
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
- "Distilled facts about you, always loaded as context for new conversations. The journal extractor appends here automatically; you can also hand-edit.",
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: "Cola de elementos del programador",
611
- summary: "Invocaciones programadas listas para dispararse. Gestionado por el agente; no edites a mano salvo que entiendas cada campo.",
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
- "Hechos destilados sobre ti, cargados siempre como contexto en conversaciones nuevas. El extractor del journal añade automáticamente; también puedes editar a mano.",
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: "File d'éléments du planificateur",
606
- summary: "Invocations planifiées prêtes à se déclencher. Géré par l'agent ; n'éditez pas à la main sans comprendre chaque champ.",
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
- "Faits distillés à votre sujet, toujours chargés comme contexte pour les nouvelles conversations. L'extracteur du journal y ajoute automatiquement ; vous pouvez aussi éditer à la main.",
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: "新しい会話のコンテキストとして常に読み込まれる、あなたに関する蒸留された事実。journal の抽出器が自動追記し、手動編集も可能です。",
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: "당신에 관한 정제된 사실로, 새 대화의 컨텍스트로 항상 로드됩니다. journal 추출기가 자동으로 추가하며 수동 편집도 가능합니다.",
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: "Fila de itens do agendador",
601
- summary: "Invocações agendadas prontas para disparar. Gerenciado pelo agente; não edite manualmente sem entender cada campo.",
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
- "Fatos destilados sobre você, sempre carregados como contexto em novas conversas. O extrator do journal adiciona automaticamente; você também pode editar manualmente.",
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: "关于你的精炼事实,作为新对话的上下文始终加载。journal 提取器会自动追加,也可手动编辑。",
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/api";
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
- // Future extension hooks (see #272 for auth token):
29
- // - setAuthToken() populates a module-level token used by every call
30
- // - interceptors could go here for logging, retry, metrics
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; other handling is the caller's responsibility.
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
- export async function apiFetchRaw(path: string, opts: RawOptions = {}): Promise<Response> {
329
- const url = `${path}${buildQueryString(opts.query)}`;
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
- return fetch(url, init);
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('meta[name="mulmoclaude-auth"]');
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;