mulmoclaude 0.1.2 → 0.4.0

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 (251) hide show
  1. package/bin/mulmoclaude.js +7 -24
  2. package/client/assets/html2canvas-Cx501zZr-Cv5snK9D.js +5 -0
  3. package/client/assets/index-CubzmCVK.css +2 -0
  4. package/client/assets/{index-D8rhwXLq.js → index-DtcyExH9.js} +80 -61
  5. package/client/assets/{index.es-D4YyL_Dg-BfRHLTZV.js → index.es-D4YyL_Dg-DnizuhIY.js} +5 -5
  6. package/client/index.html +2 -4
  7. package/package.json +13 -13
  8. package/server/agent/attachmentConverter.ts +2 -2
  9. package/server/agent/config.ts +12 -12
  10. package/server/agent/index.ts +9 -3
  11. package/server/agent/mcp-server.ts +19 -19
  12. package/server/agent/mcp-tools/index.ts +6 -6
  13. package/server/agent/mcp-tools/x.ts +7 -6
  14. package/server/agent/prompt.ts +195 -29
  15. package/server/agent/resumeFailover.ts +5 -5
  16. package/server/agent/sandboxMounts.ts +10 -10
  17. package/server/agent/stream.ts +4 -4
  18. package/server/api/auth/bearerAuth.ts +3 -3
  19. package/server/api/auth/token.ts +2 -2
  20. package/server/api/routes/agent.ts +21 -3
  21. package/server/api/routes/config.ts +1 -1
  22. package/server/api/routes/files.ts +22 -21
  23. package/server/api/routes/html.ts +2 -2
  24. package/server/api/routes/image.ts +7 -7
  25. package/server/api/routes/mulmo-script.ts +33 -31
  26. package/server/api/routes/pdf.ts +2 -2
  27. package/server/api/routes/plugins.ts +16 -6
  28. package/server/api/routes/roles.ts +2 -2
  29. package/server/api/routes/scheduler.ts +14 -12
  30. package/server/api/routes/schedulerHandlers.ts +12 -12
  31. package/server/api/routes/schedulerTasks.ts +19 -17
  32. package/server/api/routes/sessions.ts +26 -26
  33. package/server/api/routes/sessionsCursor.ts +4 -4
  34. package/server/api/routes/skills.ts +5 -5
  35. package/server/api/routes/sources.ts +3 -3
  36. package/server/api/routes/todosColumnsHandlers.ts +30 -30
  37. package/server/api/routes/todosHandlers.ts +1 -1
  38. package/server/api/routes/todosItemsHandlers.ts +14 -14
  39. package/server/api/routes/wiki.ts +36 -22
  40. package/server/api/sandboxStatus.ts +1 -1
  41. package/server/events/notifications.ts +6 -6
  42. package/server/events/pub-sub/index.ts +3 -3
  43. package/server/events/relay-client.ts +17 -16
  44. package/server/events/scheduler-adapter.ts +20 -20
  45. package/server/events/session-store/index.ts +10 -10
  46. package/server/events/task-manager/index.ts +7 -7
  47. package/server/index.ts +59 -65
  48. package/server/system/config.ts +5 -5
  49. package/server/system/credentials.ts +7 -5
  50. package/server/system/env.ts +5 -5
  51. package/server/utils/date.ts +18 -18
  52. package/server/utils/files/atomic.ts +16 -16
  53. package/server/utils/files/html-io.ts +5 -5
  54. package/server/utils/files/image-store.ts +19 -8
  55. package/server/utils/files/journal-io.ts +4 -4
  56. package/server/utils/files/json.ts +5 -5
  57. package/server/utils/files/markdown-store.ts +4 -4
  58. package/server/utils/files/naming.ts +2 -2
  59. package/server/utils/files/reference-dirs-io.ts +3 -3
  60. package/server/utils/files/roles-io.ts +12 -12
  61. package/server/utils/files/safe.ts +14 -14
  62. package/server/utils/files/scheduler-io.ts +5 -5
  63. package/server/utils/files/scheduler-overrides-io.ts +2 -2
  64. package/server/utils/files/session-io.ts +35 -35
  65. package/server/utils/files/spreadsheet-store.ts +7 -7
  66. package/server/utils/files/todos-io.ts +9 -9
  67. package/server/utils/files/user-tasks-io.ts +5 -5
  68. package/server/utils/files/workspace-io.ts +12 -12
  69. package/server/utils/gemini.ts +2 -2
  70. package/server/utils/gitignore.ts +9 -9
  71. package/server/utils/json.ts +5 -5
  72. package/server/utils/logBackgroundError.ts +12 -3
  73. package/server/utils/markdown.ts +5 -5
  74. package/server/utils/port.d.mts +6 -0
  75. package/server/utils/port.mjs +48 -0
  76. package/server/utils/request.ts +12 -6
  77. package/server/utils/spawn.ts +1 -1
  78. package/server/utils/types.ts +2 -2
  79. package/server/workspace/chat-index/indexer.ts +15 -15
  80. package/server/workspace/chat-index/summarizer.ts +4 -4
  81. package/server/workspace/custom-dirs.ts +16 -16
  82. package/server/workspace/journal/archivist.ts +35 -35
  83. package/server/workspace/journal/dailyPass.ts +31 -28
  84. package/server/workspace/journal/diff.ts +2 -2
  85. package/server/workspace/journal/index.ts +4 -4
  86. package/server/workspace/journal/indexFile.ts +29 -25
  87. package/server/workspace/journal/optimizationPass.ts +2 -2
  88. package/server/workspace/journal/state.ts +6 -6
  89. package/server/workspace/paths.ts +3 -3
  90. package/server/workspace/reference-dirs.ts +20 -20
  91. package/server/workspace/roles.ts +6 -6
  92. package/server/workspace/skills/discovery.ts +4 -4
  93. package/server/workspace/skills/parser.ts +6 -6
  94. package/server/workspace/skills/scheduler.ts +3 -3
  95. package/server/workspace/skills/user-tasks.ts +34 -34
  96. package/server/workspace/skills/writer.ts +3 -3
  97. package/server/workspace/sources/arxivDiscovery.ts +10 -10
  98. package/server/workspace/sources/classifier.ts +7 -7
  99. package/server/workspace/sources/fetchers/arxiv.ts +7 -7
  100. package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
  101. package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
  102. package/server/workspace/sources/fetchers/rss.ts +5 -5
  103. package/server/workspace/sources/fetchers/rssParser.ts +4 -4
  104. package/server/workspace/sources/interests.ts +12 -12
  105. package/server/workspace/sources/paths.ts +6 -6
  106. package/server/workspace/sources/pipeline/fetch.ts +36 -13
  107. package/server/workspace/sources/pipeline/index.ts +8 -13
  108. package/server/workspace/sources/pipeline/notify.ts +3 -3
  109. package/server/workspace/sources/pipeline/plan.ts +15 -13
  110. package/server/workspace/sources/pipeline/write.ts +5 -5
  111. package/server/workspace/sources/rateLimiter.ts +1 -1
  112. package/server/workspace/sources/registry.ts +16 -16
  113. package/server/workspace/sources/robots.ts +14 -14
  114. package/server/workspace/sources/sourceState.ts +17 -10
  115. package/server/workspace/sources/types.ts +9 -0
  116. package/server/workspace/sources/urls.ts +1 -1
  117. package/server/workspace/tool-trace/classify.ts +4 -4
  118. package/server/workspace/tool-trace/index.ts +1 -1
  119. package/server/workspace/tool-trace/writeSearch.ts +26 -16
  120. package/server/workspace/wiki-backlinks/index.ts +8 -8
  121. package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
  122. package/server/workspace/workspace.ts +7 -7
  123. package/src/App.vue +315 -141
  124. package/src/components/CanvasViewToggle.vue +10 -7
  125. package/src/components/ChatInput.vue +67 -33
  126. package/src/components/FileContentHeader.vue +7 -4
  127. package/src/components/FileContentRenderer.vue +20 -6
  128. package/src/components/FileTree.vue +6 -3
  129. package/src/components/FileTreePane.vue +11 -8
  130. package/src/components/FilesView.vue +5 -3
  131. package/src/components/LockStatusPopup.vue +17 -14
  132. package/src/components/NotificationBell.vue +14 -5
  133. package/src/components/NotificationToast.vue +6 -3
  134. package/src/components/PluginLauncher.vue +19 -56
  135. package/src/components/RightSidebar.vue +13 -10
  136. package/src/components/RoleSelector.vue +2 -2
  137. package/src/components/SessionHistoryPanel.vue +38 -34
  138. package/src/components/SessionTabBar.vue +8 -10
  139. package/src/components/SettingsMcpTab.vue +49 -36
  140. package/src/components/SettingsModal.vue +24 -22
  141. package/src/components/SettingsReferenceDirsTab.vue +39 -34
  142. package/src/components/SettingsWorkspaceDirsTab.vue +37 -27
  143. package/src/components/SidebarHeader.vue +25 -4
  144. package/src/components/StackView.vue +4 -1
  145. package/src/components/SuggestionsPanel.vue +7 -4
  146. package/src/components/TodoExplorer.vue +26 -15
  147. package/src/components/ToolResultsPanel.vue +27 -13
  148. package/src/components/todo/TodoAddDialog.vue +19 -14
  149. package/src/components/todo/TodoEditDialog.vue +7 -2
  150. package/src/components/todo/TodoEditPanel.vue +17 -12
  151. package/src/components/todo/TodoKanbanView.vue +10 -5
  152. package/src/components/todo/TodoListView.vue +10 -7
  153. package/src/components/todo/TodoTableView.vue +5 -2
  154. package/src/composables/useAppApi.ts +9 -0
  155. package/src/composables/useClickOutside.ts +2 -2
  156. package/src/composables/useDynamicFavicon.ts +172 -37
  157. package/src/composables/useEventListeners.ts +7 -8
  158. package/src/composables/useFaviconState.ts +13 -2
  159. package/src/composables/useFileSelection.ts +24 -6
  160. package/src/composables/useFreshPluginData.ts +3 -3
  161. package/src/composables/useKeyNavigation.ts +11 -11
  162. package/src/composables/useLayoutMode.ts +32 -0
  163. package/src/composables/useMcpTools.ts +2 -2
  164. package/src/composables/useNotifications.ts +3 -3
  165. package/src/composables/usePdfDownload.ts +4 -4
  166. package/src/composables/usePendingCalls.ts +1 -1
  167. package/src/composables/usePubSub.ts +10 -10
  168. package/src/composables/useRoles.ts +1 -1
  169. package/src/composables/useSandboxStatus.ts +1 -1
  170. package/src/composables/useSessionDerived.ts +3 -3
  171. package/src/composables/useSessionHistory.ts +7 -17
  172. package/src/composables/useSessionSync.ts +8 -8
  173. package/src/composables/useViewLayout.ts +20 -34
  174. package/src/config/roles.ts +2 -2
  175. package/src/lang/de.ts +536 -0
  176. package/src/lang/en.ts +558 -0
  177. package/src/lang/es.ts +543 -0
  178. package/src/lang/fr.ts +536 -0
  179. package/src/lang/ja.ts +536 -0
  180. package/src/lang/ko.ts +540 -0
  181. package/src/lang/pt-BR.ts +534 -0
  182. package/src/lang/zh.ts +537 -0
  183. package/src/lib/vue-i18n.ts +97 -0
  184. package/src/main.ts +2 -0
  185. package/src/plugins/canvas/View.vue +102 -186
  186. package/src/plugins/canvas/definition.ts +0 -8
  187. package/src/plugins/chart/Preview.vue +5 -5
  188. package/src/plugins/chart/View.vue +9 -4
  189. package/src/plugins/manageRoles/Preview.vue +4 -1
  190. package/src/plugins/manageRoles/View.vue +59 -43
  191. package/src/plugins/manageSkills/Preview.vue +8 -3
  192. package/src/plugins/manageSkills/View.vue +29 -25
  193. package/src/plugins/manageSource/Preview.vue +2 -2
  194. package/src/plugins/manageSource/View.vue +73 -52
  195. package/src/plugins/markdown/Preview.vue +1 -1
  196. package/src/plugins/markdown/View.vue +26 -36
  197. package/src/plugins/presentHtml/Preview.vue +1 -1
  198. package/src/plugins/presentHtml/View.vue +7 -4
  199. package/src/plugins/presentHtml/helpers.ts +8 -8
  200. package/src/plugins/presentMulmoScript/Preview.vue +1 -1
  201. package/src/plugins/presentMulmoScript/View.vue +40 -30
  202. package/src/plugins/presentMulmoScript/helpers.ts +1 -1
  203. package/src/plugins/scheduler/Preview.vue +13 -10
  204. package/src/plugins/scheduler/TasksTab.vue +57 -28
  205. package/src/plugins/scheduler/View.vue +28 -19
  206. package/src/plugins/scheduler/formatSchedule.ts +93 -0
  207. package/src/plugins/spreadsheet/Preview.vue +8 -3
  208. package/src/plugins/spreadsheet/View.vue +21 -12
  209. package/src/plugins/textResponse/Preview.vue +15 -58
  210. package/src/plugins/textResponse/View.vue +29 -9
  211. package/src/plugins/todo/Preview.vue +13 -8
  212. package/src/plugins/todo/View.vue +38 -24
  213. package/src/plugins/todo/composables/useTodos.ts +5 -5
  214. package/src/plugins/ui-image/ImagePreview.vue +6 -3
  215. package/src/plugins/ui-image/ImageView.vue +7 -4
  216. package/src/plugins/wiki/Preview.vue +10 -7
  217. package/src/plugins/wiki/View.vue +202 -81
  218. package/src/plugins/wiki/helpers.ts +4 -4
  219. package/src/plugins/wiki/route.ts +112 -0
  220. package/src/router/guards.ts +46 -28
  221. package/src/router/index.ts +41 -26
  222. package/src/types/session.ts +4 -3
  223. package/src/types/vue-i18n.d.ts +20 -0
  224. package/src/utils/agent/request.ts +22 -3
  225. package/src/utils/canvas/layoutMode.ts +26 -0
  226. package/src/utils/dom/scrollable.ts +2 -2
  227. package/src/utils/files/expandedDirs.ts +1 -1
  228. package/src/utils/files/sortChildren.ts +6 -6
  229. package/src/utils/format/frontmatter.ts +6 -6
  230. package/src/utils/image/cacheBust.ts +16 -0
  231. package/src/utils/image/resolve.ts +16 -0
  232. package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
  233. package/src/utils/markdown/extractFirstH1.ts +2 -2
  234. package/src/utils/path/relativeLink.ts +15 -15
  235. package/src/utils/path/workspaceLinkRouter.ts +81 -0
  236. package/src/utils/role/icon.ts +2 -2
  237. package/src/utils/role/merge.ts +2 -2
  238. package/src/utils/role/plugins.ts +1 -1
  239. package/src/utils/session/sessionFactory.ts +2 -2
  240. package/src/utils/session/sessionHelpers.ts +2 -2
  241. package/src/utils/tools/dedup.ts +4 -4
  242. package/src/utils/tools/result.ts +3 -3
  243. package/src/utils/types.ts +2 -2
  244. package/src/vite-env.d.ts +9 -0
  245. package/client/assets/chunk-vKJrgz-R-C_I3GbVV.js +0 -1
  246. package/client/assets/html2canvas-Cx501zZr-BF5dYYkY.js +0 -5
  247. package/client/assets/index-KNLBjwuh.css +0 -1
  248. package/client/assets/typeof-DBp4T-Ny-BC0P-2DM.js +0 -1
  249. package/src/composables/useCanvasViewMode.ts +0 -121
  250. package/src/utils/canvas/viewMode.ts +0 -46
  251. /package/client/assets/{purify.es-Fx1Nqyry-PeS5RUhs.js → purify.es-Fx1Nqyry-BwJECkqS.js} +0 -0
@@ -7,7 +7,8 @@
7
7
  // doesn't push a history entry).
8
8
 
9
9
  import type { Router } from "vue-router";
10
- import { VALID_VIEW_MODES } from "../utils/canvas/viewMode";
10
+ import { readPathMatch } from "../composables/useFileSelection";
11
+ import { readWikiRouteTarget } from "../plugins/wiki/route";
11
12
 
12
13
  // Basic sanity check for a session ID. Real existence verification
13
14
  // happens in App.vue's onMounted / loadSession — we can't do async
@@ -18,43 +19,60 @@ import { VALID_VIEW_MODES } from "../utils/canvas/viewMode";
18
19
  // doesn't exist on the server" gracefully.
19
20
  const SESSION_ID_RE = /^[\w-]{1,128}$/;
20
21
 
21
- function isValidSessionId(id: unknown): boolean {
22
- return typeof id === "string" && SESSION_ID_RE.test(id);
22
+ function isValidSessionId(value: unknown): boolean {
23
+ return typeof value === "string" && SESSION_ID_RE.test(value);
23
24
  }
24
25
 
25
26
  export function installGuards(router: Router): void {
26
- router.beforeEach((to) => {
27
- // Only run guards on the chat route — other routes (redirect, etc.)
28
- // don't carry parameters that need sanitizing.
29
- if (to.name !== "chat") return;
30
-
31
- // ── sessionId format check ───────────────────────────────────
32
- const sessionId = to.params.sessionId;
33
- if (typeof sessionId === "string" && sessionId.length > 0 && !isValidSessionId(sessionId)) {
34
- // Garbage sessionId → strip it and go to /chat (new session).
35
- return { name: "chat", params: {}, query: {}, replace: true };
27
+ router.beforeEach((dest) => {
28
+ if (dest.name === "chat") {
29
+ const sessionId = dest.params.sessionId;
30
+ if (typeof sessionId === "string" && sessionId.length > 0 && !isValidSessionId(sessionId)) {
31
+ return { name: "chat", params: {}, query: {}, replace: true };
32
+ }
36
33
  }
37
34
 
38
- // ── view mode whitelist ──────────────────────────────────────
39
- const view = to.query.view;
40
- if (typeof view === "string" && !VALID_VIEW_MODES.has(view)) {
41
- const cleaned = { ...to.query };
42
- delete cleaned.view;
43
- return { ...to, query: cleaned, replace: true };
35
+ if (dest.name === "wiki") {
36
+ // Vue Router decodes `%2F` back to `/` in `route.params.slug`,
37
+ // so `/wiki/pages/..%2Fsecrets` arrives here as
38
+ // `{ section: "pages", slug: "../secrets" }`. `readWikiRouteTarget`
39
+ // returns `null` for unsafe slugs, a missing slug on the `pages`
40
+ // section, or an unknown section. In any of those cases, bounce
41
+ // to `/wiki` with `replace: true` so no broken URL lands in
42
+ // history. Legal targets fall through to the view, where the
43
+ // route watcher drives the fetch.
44
+ if (readWikiRouteTarget(dest.params) === null) {
45
+ return { name: "wiki", params: {}, query: dest.query, replace: true };
46
+ }
44
47
  }
45
48
 
46
- // ── file path traversal check ────────────────────────────────
47
- const filePath = to.query.path;
48
- if (typeof filePath === "string") {
49
- if (filePath.includes("..") || filePath.startsWith("/")) {
50
- const cleaned = { ...to.query };
49
+ if (dest.name === "files") {
50
+ // Back-compat: old query-string form `/files?path=foo.md` →
51
+ // rewrite to the new path form `/files/foo.md`. Silent
52
+ // replace so bookmarks / log links keep working. Do this
53
+ // before the traversal check so `?path=../bad` also lands in
54
+ // the `..` rejection below.
55
+ const legacyPath = dest.query.path;
56
+ if (typeof legacyPath === "string" && legacyPath.length > 0) {
57
+ const cleaned = { ...dest.query };
51
58
  delete cleaned.path;
52
- return { ...to, query: cleaned, replace: true };
59
+ return {
60
+ name: "files",
61
+ params: { pathMatch: legacyPath.split("/") },
62
+ query: cleaned,
63
+ replace: true,
64
+ };
53
65
  }
54
66
 
55
- // ?path= without ?view=files auto-add view=files so FilesView mounts.
56
- if (view !== "files") {
57
- return { ...to, query: { ...to.query, view: "files" }, replace: true };
67
+ // Traversal / absolute-path rejection against the new param.
68
+ const filePath = readPathMatch(dest.params.pathMatch);
69
+ if (typeof filePath === "string" && (filePath.includes("..") || filePath.startsWith("/"))) {
70
+ return {
71
+ name: "files",
72
+ params: { pathMatch: [] },
73
+ query: dest.query,
74
+ replace: true,
75
+ };
58
76
  }
59
77
  }
60
78
  });
@@ -1,12 +1,9 @@
1
1
  // Vue-router setup (history mode — clean URLs without #).
2
2
  //
3
- // The route is /chat/:sessionId? which captures the session the user
4
- // was looking at. Everything else (view mode, file path, result uuid,
5
- // role) lives in query parameters and will be wired in later phases.
6
- //
7
- // The "/" → "/chat" redirect ensures a fresh browser tab always lands
8
- // on the chat view with the default (new) session, matching the
9
- // current pre-router behaviour.
3
+ // Each page has its own route: /chat, /files, /todos, /scheduler,
4
+ // /wiki, /skills, /roles. Layout preference (single vs. stack) is a
5
+ // separate concern persisted in localStorage it is not part of the
6
+ // URL.
10
7
  //
11
8
  // History mode requires the server to serve index.html for any path
12
9
  // that doesn't match an API route or static file. In production the
@@ -18,28 +15,46 @@ import { createRouter, createWebHistory, type RouteRecordRaw } from "vue-router"
18
15
 
19
16
  // Stub component that renders nothing. Required by vue-router (every
20
17
  // route needs a component) but never actually mounted because App.vue
21
- // doesn't contain <router-view> in Phase 0.
18
+ // renders based on `route.name` rather than using <router-view>.
22
19
  const Stub = defineComponent({ render: () => h("div") });
23
20
 
21
+ export const PAGE_ROUTES = {
22
+ chat: "chat",
23
+ files: "files",
24
+ todos: "todos",
25
+ scheduler: "scheduler",
26
+ wiki: "wiki",
27
+ skills: "skills",
28
+ roles: "roles",
29
+ history: "history",
30
+ } as const;
31
+
32
+ export type PageRouteName = (typeof PAGE_ROUTES)[keyof typeof PAGE_ROUTES];
33
+
24
34
  const routes: RouteRecordRaw[] = [
25
- {
26
- path: "/",
27
- redirect: "/chat",
28
- },
29
- {
30
- // sessionId is optional /chat with no param means "new session"
31
- // (the App.vue logic that auto-creates a session continues to
32
- // handle this).
33
- path: "/chat/:sessionId?",
34
- name: "chat",
35
- component: Stub,
36
- },
37
- {
38
- // Catch-all: unknown paths redirect to /chat so a stale bookmark
39
- // or a typo doesn't show a blank page.
40
- path: "/:pathMatch(.*)*",
41
- redirect: "/chat",
42
- },
35
+ { path: "/", redirect: "/chat" },
36
+ { path: "/chat/:sessionId?", name: PAGE_ROUTES.chat, component: Stub },
37
+ // Files view uses a repeatable catch-all so `/files/a/b/c.md` maps
38
+ // to `params.pathMatch = ["a", "b", "c.md"]`. Joining on `/` at read
39
+ // time keeps each segment URL-encoded independently — passing a
40
+ // string-form catch-all (`:pathMatch(.*)`) would collapse slashes
41
+ // to `%2F` at push time and mangle deep paths. An empty segment
42
+ // (`/files`) yields an empty array, which we treat as "no file
43
+ // selected". See plans/feat-files-path-url.md.
44
+ { path: "/files/:pathMatch(.*)*", name: PAGE_ROUTES.files, component: Stub },
45
+ { path: "/todos", name: PAGE_ROUTES.todos, component: Stub },
46
+ { path: "/scheduler", name: PAGE_ROUTES.scheduler, component: Stub },
47
+ // Wiki sub-views live on the path rather than in query params so
48
+ // URLs mirror the filesystem layout (`data/wiki/pages/<slug>.md`)
49
+ // and stay sibling-safe (no query-key bleed from other routes).
50
+ // `section` is a closed enum; unknown sections fall through to the
51
+ // catch-all redirect below. `slug` only applies when `section ===
52
+ // "pages"`. See plans/feat-wiki-path-urls.md.
53
+ { path: "/wiki/:section(pages|log|lint-report)?/:slug?", name: PAGE_ROUTES.wiki, component: Stub },
54
+ { path: "/skills", name: PAGE_ROUTES.skills, component: Stub },
55
+ { path: "/roles", name: PAGE_ROUTES.roles, component: Stub },
56
+ { path: "/history", name: PAGE_ROUTES.history, component: Stub },
57
+ { path: "/:pathMatch(.*)*", redirect: "/chat" },
43
58
  ];
44
59
 
45
60
  const router = createRouter({
@@ -70,10 +70,11 @@ export interface ToolResultEntry extends SessionEntry {
70
70
  result: ToolResultComplete;
71
71
  }
72
72
 
73
- export const isTextEntry = (e: SessionEntry): e is TextEntry =>
74
- (e.source === "user" || e.source === "assistant") && e.type === EVENT_TYPES.text && typeof e.message === "string";
73
+ export const isTextEntry = (entry: SessionEntry): entry is TextEntry =>
74
+ (entry.source === "user" || entry.source === "assistant") && entry.type === EVENT_TYPES.text && typeof entry.message === "string";
75
75
 
76
- export const isToolResultEntry = (e: SessionEntry): e is ToolResultEntry => e.source === "tool" && e.type === EVENT_TYPES.toolResult && e.result !== undefined;
76
+ export const isToolResultEntry = (entry: SessionEntry): entry is ToolResultEntry =>
77
+ entry.source === "tool" && entry.type === EVENT_TYPES.toolResult && entry.result !== undefined;
77
78
 
78
79
  // In-memory session held in `sessionMap`. PR #88 introduced this so
79
80
  // multiple chats can run concurrently — `id` matches the `chatSessionId`
@@ -0,0 +1,20 @@
1
+ // Module augmentation for vue-i18n. Surfaces the English dictionary
2
+ // shape as `DefineLocaleMessage` so IDEs autocomplete key paths on
3
+ // `$t("…")` and `i18n.global.t("…")`. The schema generic on
4
+ // createI18n (src/lib/vue-i18n.ts) handles the `useI18n()` side.
5
+ //
6
+ // Caveat: vue-i18n v11's `t()` overload has a `Key extends string`
7
+ // fallback, so a typo in `useI18n().t("…")` at a deeply-typed call
8
+ // site may still compile. Autocomplete is the main value; strict
9
+ // rejection of unknown keys would require a bespoke wrapper.
10
+
11
+ import enMessages from "../lang/en";
12
+
13
+ // Alias so `extends` has an interface-shaped base (typeof in extends
14
+ // position is a syntax error).
15
+ type EnMessages = typeof enMessages;
16
+
17
+ declare module "vue-i18n" {
18
+ // eslint-disable-next-line @typescript-eslint/no-empty-object-type
19
+ export interface DefineLocaleMessage extends EnMessages {}
20
+ }
@@ -16,6 +16,24 @@ export interface AgentRequestBody {
16
16
  roleId: string;
17
17
  chatSessionId: string;
18
18
  selectedImageData: string | undefined;
19
+ // IANA identifier (e.g. "Asia/Tokyo", "America/New_York"). The
20
+ // server uses this to interpret bare time expressions in the user's
21
+ // message without asking for clarification every turn. Undefined if
22
+ // the browser can't resolve a timezone — the server then falls back
23
+ // to its own local time and asks as before.
24
+ userTimezone: string | undefined;
25
+ }
26
+
27
+ // `Intl.DateTimeFormat().resolvedOptions().timeZone` can, in theory,
28
+ // throw in some locked-down environments; wrap so a broken Intl
29
+ // doesn't take down the send path.
30
+ function resolveBrowserTimezone(): string | undefined {
31
+ try {
32
+ const zoneId = Intl.DateTimeFormat().resolvedOptions().timeZone;
33
+ return typeof zoneId === "string" && zoneId.length > 0 ? zoneId : undefined;
34
+ } catch {
35
+ return undefined;
36
+ }
19
37
  }
20
38
 
21
39
  export function buildAgentRequestBody(params: AgentRequestBodyParams): AgentRequestBody {
@@ -24,6 +42,7 @@ export function buildAgentRequestBody(params: AgentRequestBodyParams): AgentRequ
24
42
  roleId: params.role.id,
25
43
  chatSessionId: params.chatSessionId,
26
44
  selectedImageData: params.selectedImageData,
45
+ userTimezone: resolveBrowserTimezone(),
27
46
  };
28
47
  }
29
48
 
@@ -45,11 +64,11 @@ export async function postAgentRun(body: AgentRequestBody): Promise<{ ok: true }
45
64
  };
46
65
  }
47
66
  return { ok: true };
48
- } catch (e) {
49
- console.error("[agent] fetch error:", e);
67
+ } catch (err) {
68
+ console.error("[agent] fetch error:", err);
50
69
  return {
51
70
  ok: false,
52
- error: e instanceof Error ? e.message : "Connection error.",
71
+ error: err instanceof Error ? err.message : "Connection error.",
53
72
  };
54
73
  }
55
74
  }
@@ -0,0 +1,26 @@
1
+ // Pure helpers for the canvas layout mode.
2
+ //
3
+ // Layout is a user preference — single (sidebar + canvas) vs. stack
4
+ // (no sidebar, full-width canvas). It applies only to the /chat page
5
+ // and persists in localStorage.
6
+ //
7
+ // Pages like /files, /todos, /wiki, /skills, /roles, /scheduler are
8
+ // distinct routes, not layout variants. They live in the router, not
9
+ // here.
10
+
11
+ export const LAYOUT_MODES = {
12
+ single: "single",
13
+ stack: "stack",
14
+ } as const;
15
+
16
+ export type LayoutMode = (typeof LAYOUT_MODES)[keyof typeof LAYOUT_MODES];
17
+
18
+ export const LAYOUT_MODE_STORAGE_KEY = "canvas_layout_mode";
19
+
20
+ // Legacy key from before layout/page were split. Deleted on first
21
+ // read of useLayoutMode — not migrated (fresh start).
22
+ export const LEGACY_VIEW_MODE_STORAGE_KEY = "canvas_view_mode";
23
+
24
+ export function parseStoredLayoutMode(stored: string | null): LayoutMode {
25
+ return stored === LAYOUT_MODES.stack ? LAYOUT_MODES.stack : LAYOUT_MODES.single;
26
+ }
@@ -11,8 +11,8 @@
11
11
  // real DOM.
12
12
  export function findScrollableChild(container: HTMLElement): HTMLElement | null {
13
13
  const children = container.querySelectorAll("*");
14
- for (const el of children) {
15
- const html = el as HTMLElement;
14
+ for (const elem of children) {
15
+ const html = elem as HTMLElement;
16
16
  if (html.scrollHeight > html.clientHeight) {
17
17
  const style = getComputedStyle(html);
18
18
  if (style.overflowY === "auto" || style.overflowY === "scroll" || style.overflow === "auto" || style.overflow === "scroll") {
@@ -13,7 +13,7 @@ export function parseStoredExpandedDirs(raw: string | null): Set<string> {
13
13
  try {
14
14
  const parsed: unknown = JSON.parse(raw);
15
15
  if (!Array.isArray(parsed)) return new Set(DEFAULT_EXPANDED);
16
- const strings = parsed.filter((v): v is string => typeof v === "string");
16
+ const strings = parsed.filter((val): val is string => typeof val === "string");
17
17
  return new Set(strings);
18
18
  } catch {
19
19
  return new Set(DEFAULT_EXPANDED);
@@ -7,14 +7,14 @@ import type { FileSortMode } from "../../composables/useFileSortMode";
7
7
  // on name so the order is deterministic).
8
8
  export function sortChildren(children: readonly TreeNode[], mode: FileSortMode): TreeNode[] {
9
9
  const copy = children.slice();
10
- copy.sort((a, b) => {
11
- if (a.type !== b.type) return a.type === "dir" ? -1 : 1;
10
+ copy.sort((nodeA, nodeB) => {
11
+ if (nodeA.type !== nodeB.type) return nodeA.type === "dir" ? -1 : 1;
12
12
  if (mode === "recent") {
13
- const am = a.modifiedMs ?? -Infinity;
14
- const bm = b.modifiedMs ?? -Infinity;
15
- if (am !== bm) return bm - am;
13
+ const modTimeA = nodeA.modifiedMs ?? -Infinity;
14
+ const modTimeB = nodeB.modifiedMs ?? -Infinity;
15
+ if (modTimeA !== modTimeB) return modTimeB - modTimeA;
16
16
  }
17
- return a.name.localeCompare(b.name);
17
+ return nodeA.name.localeCompare(nodeB.name);
18
18
  });
19
19
  return copy;
20
20
  }
@@ -66,15 +66,15 @@ function parseValue(raw: string): FrontmatterValue {
66
66
  if (arrayMatch) {
67
67
  return arrayMatch[1]
68
68
  .split(",")
69
- .map((s) => unquote(s.trim()))
70
- .filter((s) => s.length > 0);
69
+ .map((item) => unquote(item.trim()))
70
+ .filter((item) => item.length > 0);
71
71
  }
72
72
  return unquote(raw);
73
73
  }
74
74
 
75
- function unquote(s: string): string {
76
- if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
77
- return s.slice(1, -1);
75
+ function unquote(str: string): string {
76
+ if ((str.startsWith('"') && str.endsWith('"')) || (str.startsWith("'") && str.endsWith("'"))) {
77
+ return str.slice(1, -1);
78
78
  }
79
- return s;
79
+ return str;
80
80
  }
@@ -0,0 +1,16 @@
1
+ import { reactive } from "vue";
2
+
3
+ // Keyed by workspace-relative image path (e.g. "artifacts/images/abc.png").
4
+ // `resolveImageSrc` reads this to append `?v=<bump>` to the URL so consumers
5
+ // (View, Preview) re-fetch when the file on disk has been overwritten in
6
+ // place. The canvas plugin is the current producer — it bumps after each
7
+ // autosave PUT.
8
+ const imageBumps = reactive<Record<string, number>>({});
9
+
10
+ export function getImageBump(imagePath: string): number {
11
+ return imageBumps[imagePath] ?? 0;
12
+ }
13
+
14
+ export function bumpImage(imagePath: string): void {
15
+ imageBumps[imagePath] = Date.now();
16
+ }
@@ -1,4 +1,5 @@
1
1
  import { API_ROUTES } from "../../config/apiRoutes";
2
+ import { getImageBump } from "./cacheBust";
2
3
 
3
4
  /** Convert an imageData value to a displayable URL.
4
5
  * Handles both legacy data URIs and workspace-relative file paths. */
@@ -6,3 +7,18 @@ export function resolveImageSrc(imageData: string): string {
6
7
  if (imageData.startsWith("data:")) return imageData;
7
8
  return `${API_ROUTES.files.raw}?path=${encodeURIComponent(imageData)}`;
8
9
  }
10
+
11
+ /** Same as `resolveImageSrc` but appends the current cache-bust token
12
+ * so the browser re-fetches when the file has been overwritten in
13
+ * place (e.g. the canvas plugin rewrote it).
14
+ *
15
+ * Use this from display-only consumers (Preview, thumbnail list).
16
+ * Avoid inside the canvas View's own `backgroundImage` — changing
17
+ * that URL mid-session makes `vue-drawing-canvas` re-fetch on every
18
+ * redraw, which races with stroke painting and blanks the canvas. */
19
+ export function resolveImageSrcFresh(imageData: string): string {
20
+ if (imageData.startsWith("data:")) return imageData;
21
+ const base = `${API_ROUTES.files.raw}?path=${encodeURIComponent(imageData)}`;
22
+ const bump = getImageBump(imageData);
23
+ return bump > 0 ? `${base}&v=${bump}` : base;
24
+ }
@@ -51,7 +51,7 @@ function shouldSkip(url: string): boolean {
51
51
  function resolveWorkspacePath(basePath: string, url: string): string | null {
52
52
  // Absolute-within-workspace (e.g. "/images/foo.png") — reset base.
53
53
  const isAbsolute = url.startsWith("/");
54
- const baseSegs = isAbsolute ? [] : basePath.split("/").filter((s) => s !== "" && s !== ".");
54
+ const baseSegs = isAbsolute ? [] : basePath.split("/").filter((seg) => seg !== "" && seg !== ".");
55
55
  const segs = [...baseSegs];
56
56
 
57
57
  const urlSegs = (isAbsolute ? url.slice(1) : url).split("/");
@@ -76,9 +76,9 @@ function extractBracketedAlt(raw: string): string | null {
76
76
  if (!raw.startsWith("![")) return null;
77
77
  let depth = 1;
78
78
  for (let i = 2; i < raw.length; i++) {
79
- const c = raw[i];
80
- if (c === "[") depth++;
81
- else if (c === "]") {
79
+ const char = raw[i];
80
+ if (char === "[") depth++;
81
+ else if (char === "]") {
82
82
  depth--;
83
83
  if (depth === 0) return raw.slice(2, i);
84
84
  }
@@ -123,7 +123,7 @@ function getContainerChildren(token: Token): Token[] | null {
123
123
  // Returns true if the container was rendered via its children, false
124
124
  // if the caller should fall back to emitting the parent's raw.
125
125
  function renderContainerChildren(raw: string, children: Token[], basePath: string, out: string[]): boolean {
126
- const joined = children.map((c) => (c as { raw?: string }).raw ?? "").join("");
126
+ const joined = children.map((token) => (token as { raw?: string }).raw ?? "").join("");
127
127
  if (joined === "") return false;
128
128
  const idx = raw.indexOf(joined);
129
129
  if (idx < 0) return false;
@@ -30,8 +30,8 @@ export function extractFirstH1(markdown: string): string | null {
30
30
  return null;
31
31
  }
32
32
 
33
- function splitLines(s: string): string[] {
34
- return s.split(/\r\n|\r|\n/);
33
+ function splitLines(str: string): string[] {
34
+ return str.split(/\r\n|\r|\n/);
35
35
  }
36
36
 
37
37
  function isInlineSpace(code: number): boolean {
@@ -73,13 +73,13 @@ export function resolveWorkspaceLink(currentFilePath: string, href: string): str
73
73
 
74
74
  // Drop any trailing #fragment or ?query from a path-like string.
75
75
  // Whichever marker comes first wins.
76
- function stripFragmentAndQuery(s: string): string {
77
- const hashIdx = s.indexOf("#");
78
- const queryIdx = s.indexOf("?");
79
- let end = s.length;
76
+ function stripFragmentAndQuery(str: string): string {
77
+ const hashIdx = str.indexOf("#");
78
+ const queryIdx = str.indexOf("?");
79
+ let end = str.length;
80
80
  if (hashIdx !== -1 && hashIdx < end) end = hashIdx;
81
81
  if (queryIdx !== -1 && queryIdx < end) end = queryIdx;
82
- return s.slice(0, end);
82
+ return str.slice(0, end);
83
83
  }
84
84
 
85
85
  // If `resolvedPath` points at a chat session log (e.g.
@@ -95,26 +95,26 @@ export function extractSessionIdFromPath(resolvedPath: string): string | null {
95
95
  const JSONL_SUFFIX = ".jsonl";
96
96
  if (!resolvedPath.startsWith(CHAT_PREFIX)) return null;
97
97
  if (!resolvedPath.endsWith(JSONL_SUFFIX)) return null;
98
- const id = resolvedPath.slice(CHAT_PREFIX.length, resolvedPath.length - JSONL_SUFFIX.length);
99
- if (id.length === 0) return null;
100
- if (id.includes("/")) return null;
101
- return id;
98
+ const sessionId = resolvedPath.slice(CHAT_PREFIX.length, resolvedPath.length - JSONL_SUFFIX.length);
99
+ if (sessionId.length === 0) return null;
100
+ if (sessionId.includes("/")) return null;
101
+ return sessionId;
102
102
  }
103
103
 
104
104
  // POSIX-style dirname. The file viewer always uses "/" separators
105
105
  // so we don't need to worry about Windows paths.
106
- function posixDirname(p: string): string {
107
- const i = p.lastIndexOf("/");
108
- return i === -1 ? "" : p.slice(0, i);
106
+ function posixDirname(path: string): string {
107
+ const i = path.lastIndexOf("/");
108
+ return i === -1 ? "" : path.slice(0, i);
109
109
  }
110
110
 
111
111
  // Collapse "./" and "../" in a workspace path. Rejects paths that
112
112
  // escape above the workspace root. Returns null for the empty-path
113
113
  // case so the caller can bail out. Callers are expected to strip
114
114
  // #fragment / ?query before invoking this function.
115
- function normalizeWorkspacePath(p: string): string | null {
116
- if (p.length === 0) return null;
117
- const parts = p.split("/");
115
+ function normalizeWorkspacePath(path: string): string | null {
116
+ if (path.length === 0) return null;
117
+ const parts = path.split("/");
118
118
  const stack: string[] = [];
119
119
  for (const part of parts) {
120
120
  if (part === "" || part === ".") continue;
@@ -0,0 +1,81 @@
1
+ // Classify a workspace-relative link from agent Markdown into a
2
+ // navigation target. Used by TextResponse's click handler to route
3
+ // internal links to the appropriate view (Wiki, Files, Session)
4
+ // instead of letting them fall through to the SPA router.
5
+ //
6
+ // Pure function — no DOM or Vue dependencies, fully unit-testable.
7
+
8
+ import { isExternalHref, extractSessionIdFromPath } from "./relativeLink";
9
+
10
+ export type WorkspaceLinkTarget = { kind: "wiki"; slug: string } | { kind: "file"; path: string } | { kind: "session"; sessionId: string };
11
+
12
+ // Match `data/wiki/pages/<slug>.md` or `wiki/pages/<slug>.md`.
13
+ const WIKI_PAGE_PATTERN = /(?:data\/)?wiki\/pages\/([^/]+)\.md$/;
14
+
15
+ // Match `conversations/chat/<id>.jsonl` (delegates to extractSessionIdFromPath).
16
+ const CHAT_LOG_PREFIX = "conversations/";
17
+
18
+ /**
19
+ * Given a raw href attribute from agent Markdown, return a typed
20
+ * navigation target, or null if the link is external, anchor-only,
21
+ * or unresolvable.
22
+ *
23
+ * Agent links are typically workspace-root-relative (e.g.
24
+ * `data/wiki/pages/foo.md`). Relative paths with `../` that escape
25
+ * the workspace root return null.
26
+ */
27
+ export function classifyWorkspacePath(href: string): WorkspaceLinkTarget | null {
28
+ if (!href || isExternalHref(href)) return null;
29
+ if (href.startsWith("#")) return null;
30
+
31
+ // Strip fragment and query
32
+ const cleaned = stripFragmentAndQuery(href);
33
+ if (cleaned.length === 0) return null;
34
+
35
+ // Normalize path (collapse ./ and ../, reject root-escape)
36
+ const normalized = normalizePath(cleaned);
37
+ if (!normalized) return null;
38
+
39
+ // Wiki page: data/wiki/pages/<slug>.md
40
+ const wikiMatch = normalized.match(WIKI_PAGE_PATTERN);
41
+ if (wikiMatch) {
42
+ return { kind: "wiki", slug: wikiMatch[1] };
43
+ }
44
+
45
+ // Chat session log: conversations/chat/<id>.jsonl
46
+ if (normalized.startsWith(CHAT_LOG_PREFIX)) {
47
+ const chatPath = normalized.slice(CHAT_LOG_PREFIX.length);
48
+ const sessionId = extractSessionIdFromPath(chatPath);
49
+ if (sessionId) {
50
+ return { kind: "session", sessionId };
51
+ }
52
+ }
53
+
54
+ // Everything else: open in Files view
55
+ return { kind: "file", path: normalized };
56
+ }
57
+
58
+ function stripFragmentAndQuery(str: string): string {
59
+ const hashIdx = str.indexOf("#");
60
+ const queryIdx = str.indexOf("?");
61
+ let end = str.length;
62
+ if (hashIdx !== -1 && hashIdx < end) end = hashIdx;
63
+ if (queryIdx !== -1 && queryIdx < end) end = queryIdx;
64
+ return str.slice(0, end);
65
+ }
66
+
67
+ function normalizePath(raw: string): string | null {
68
+ if (raw.length === 0) return null;
69
+ const parts = raw.split("/");
70
+ const stack: string[] = [];
71
+ for (const part of parts) {
72
+ if (part === "" || part === ".") continue;
73
+ if (part === "..") {
74
+ if (stack.length === 0) return null;
75
+ stack.pop();
76
+ continue;
77
+ }
78
+ stack.push(part);
79
+ }
80
+ return stack.length === 0 ? null : stack.join("/");
81
+ }
@@ -11,10 +11,10 @@ import type { Role } from "../../config/roles";
11
11
  const MATERIAL_ICON_RE = /^[a-z_]+$/;
12
12
 
13
13
  export function roleIcon(roles: Role[], roleId: string): string {
14
- const icon = roles.find((r) => r.id === roleId)?.icon ?? "star";
14
+ const icon = roles.find((role) => role.id === roleId)?.icon ?? "star";
15
15
  return MATERIAL_ICON_RE.test(icon) ? icon : "smart_toy";
16
16
  }
17
17
 
18
18
  export function roleName(roles: Role[], roleId: string): string {
19
- return roles.find((r) => r.id === roleId)?.name ?? roleId;
19
+ return roles.find((role) => role.id === roleId)?.name ?? roleId;
20
20
  }
@@ -5,6 +5,6 @@
5
5
  import type { Role } from "../../config/roles";
6
6
 
7
7
  export function mergeRoles(builtin: Role[], custom: Role[]): Role[] {
8
- const customIds = new Set(custom.map((r) => r.id));
9
- return [...builtin.filter((r) => !customIds.has(r.id)), ...custom];
8
+ const customIds = new Set(custom.map((role) => role.id));
9
+ return [...builtin.filter((role) => !customIds.has(role.id)), ...custom];
10
10
  }
@@ -8,5 +8,5 @@ const GEMINI_PLUGINS = new Set<ToolName>([TOOL_NAMES.generateImage, TOOL_NAMES.p
8
8
 
9
9
  /** Whether the given role uses any plugin that requires a Gemini API key. */
10
10
  export function needsGemini(roles: Role[], roleId: string): boolean {
11
- return (roles.find((r) => r.id === roleId)?.availablePlugins ?? []).some((p) => GEMINI_PLUGINS.has(p));
11
+ return (roles.find((role) => role.id === roleId)?.availablePlugins ?? []).some((plugin) => GEMINI_PLUGINS.has(plugin));
12
12
  }
@@ -2,10 +2,10 @@
2
2
 
3
3
  import type { ActiveSession } from "../../types/session";
4
4
 
5
- export function createEmptySession(id: string, roleId: string): ActiveSession {
5
+ export function createEmptySession(sessionId: string, roleId: string): ActiveSession {
6
6
  const now = new Date().toISOString();
7
7
  return {
8
- id,
8
+ id: sessionId,
9
9
  roleId,
10
10
  toolResults: [],
11
11
  resultTimestamps: new Map(),