mulmoclaude 0.1.2 → 0.3.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.
- package/bin/mulmoclaude.js +1 -1
- package/client/assets/{index-KNLBjwuh.css → index-Bm70FDU2.css} +1 -1
- package/client/assets/{index-D8rhwXLq.js → index-eHWB79u5.js} +3 -3
- package/client/index.html +2 -2
- package/package.json +1 -1
- package/server/agent/config.ts +12 -12
- package/server/agent/mcp-server.ts +19 -19
- package/server/agent/mcp-tools/x.ts +5 -5
- package/server/agent/prompt.ts +9 -4
- package/server/agent/sandboxMounts.ts +7 -7
- package/server/agent/stream.ts +4 -4
- package/server/api/routes/files.ts +9 -9
- package/server/api/routes/scheduler.ts +8 -8
- package/server/api/routes/schedulerHandlers.ts +12 -12
- package/server/api/routes/schedulerTasks.ts +14 -14
- package/server/api/routes/sessions.ts +24 -24
- package/server/api/routes/todosColumnsHandlers.ts +30 -30
- package/server/api/routes/wiki.ts +14 -14
- package/server/events/scheduler-adapter.ts +20 -20
- package/server/events/session-store/index.ts +10 -10
- package/server/events/task-manager/index.ts +7 -7
- package/server/index.ts +19 -19
- package/server/utils/date.ts +18 -18
- package/server/utils/files/atomic.ts +9 -9
- package/server/utils/files/html-io.ts +5 -5
- package/server/utils/files/image-store.ts +2 -2
- package/server/utils/files/journal-io.ts +2 -2
- package/server/utils/files/naming.ts +2 -2
- package/server/utils/files/roles-io.ts +10 -10
- package/server/utils/files/scheduler-io.ts +5 -5
- package/server/utils/files/session-io.ts +35 -35
- package/server/utils/files/spreadsheet-store.ts +2 -2
- package/server/utils/files/todos-io.ts +9 -9
- package/server/utils/files/user-tasks-io.ts +5 -5
- package/server/workspace/chat-index/indexer.ts +15 -15
- package/server/workspace/custom-dirs.ts +11 -11
- package/server/workspace/journal/archivist.ts +35 -35
- package/server/workspace/journal/dailyPass.ts +31 -28
- package/server/workspace/journal/indexFile.ts +29 -25
- package/server/workspace/reference-dirs.ts +18 -18
- package/server/workspace/roles.ts +6 -6
- package/server/workspace/skills/discovery.ts +4 -4
- package/server/workspace/skills/user-tasks.ts +34 -34
- package/server/workspace/sources/arxivDiscovery.ts +8 -8
- package/server/workspace/sources/classifier.ts +7 -7
- package/server/workspace/sources/fetchers/arxiv.ts +7 -7
- package/server/workspace/sources/fetchers/githubIssues.ts +7 -7
- package/server/workspace/sources/fetchers/githubReleases.ts +7 -7
- package/server/workspace/sources/interests.ts +9 -9
- package/server/workspace/sources/pipeline/index.ts +6 -6
- package/server/workspace/sources/pipeline/plan.ts +5 -5
- package/server/workspace/sources/registry.ts +16 -16
- package/server/workspace/sources/robots.ts +14 -14
- package/server/workspace/sources/sourceState.ts +11 -9
- package/server/workspace/tool-trace/index.ts +1 -1
- package/server/workspace/tool-trace/writeSearch.ts +26 -16
- package/server/workspace/wiki-backlinks/index.ts +8 -8
- package/server/workspace/wiki-backlinks/sessionBacklinks.ts +15 -15
- package/src/App.vue +30 -30
- package/src/components/ChatInput.vue +7 -7
- package/src/components/LockStatusPopup.vue +2 -2
- package/src/components/NotificationToast.vue +2 -2
- package/src/components/RoleSelector.vue +2 -2
- package/src/components/SessionHistoryPanel.vue +6 -6
- package/src/components/SettingsMcpTab.vue +7 -7
- package/src/components/SettingsModal.vue +3 -3
- package/src/components/SettingsReferenceDirsTab.vue +10 -10
- package/src/components/SettingsWorkspaceDirsTab.vue +5 -5
- package/src/components/SuggestionsPanel.vue +2 -2
- package/src/components/todo/TodoAddDialog.vue +2 -2
- package/src/components/todo/TodoEditPanel.vue +2 -2
- package/src/components/todo/TodoListView.vue +5 -5
- package/src/composables/useCanvasViewMode.ts +5 -5
- package/src/composables/useClickOutside.ts +2 -2
- package/src/composables/useFreshPluginData.ts +3 -3
- package/src/composables/useKeyNavigation.ts +11 -11
- package/src/composables/useMcpTools.ts +2 -2
- package/src/composables/useNotifications.ts +3 -3
- package/src/composables/usePdfDownload.ts +4 -4
- package/src/composables/usePendingCalls.ts +1 -1
- package/src/composables/usePubSub.ts +10 -10
- package/src/composables/useRoles.ts +1 -1
- package/src/composables/useSandboxStatus.ts +1 -1
- package/src/composables/useSessionDerived.ts +3 -3
- package/src/composables/useSessionSync.ts +8 -8
- package/src/composables/useViewLayout.ts +2 -2
- package/src/config/roles.ts +2 -2
- package/src/plugins/chart/Preview.vue +4 -4
- package/src/plugins/manageSkills/View.vue +3 -3
- package/src/plugins/manageSource/Preview.vue +1 -1
- package/src/plugins/markdown/View.vue +2 -2
- package/src/plugins/presentHtml/helpers.ts +8 -8
- package/src/plugins/presentMulmoScript/View.vue +4 -4
- package/src/plugins/presentMulmoScript/helpers.ts +1 -1
- package/src/plugins/scheduler/Preview.vue +6 -6
- package/src/plugins/scheduler/TasksTab.vue +4 -4
- package/src/plugins/textResponse/View.vue +2 -2
- package/src/plugins/todo/Preview.vue +2 -2
- package/src/plugins/todo/View.vue +11 -11
- package/src/plugins/todo/composables/useTodos.ts +5 -5
- package/src/plugins/wiki/Preview.vue +5 -5
- package/src/plugins/wiki/helpers.ts +4 -4
- package/src/router/guards.ts +12 -12
- package/src/types/session.ts +4 -3
- package/src/utils/agent/request.ts +3 -3
- package/src/utils/dom/scrollable.ts +2 -2
- package/src/utils/files/expandedDirs.ts +1 -1
- package/src/utils/files/sortChildren.ts +6 -6
- package/src/utils/format/frontmatter.ts +6 -6
- package/src/utils/image/rewriteMarkdownImageRefs.ts +5 -5
- package/src/utils/markdown/extractFirstH1.ts +2 -2
- package/src/utils/path/relativeLink.ts +15 -15
- package/src/utils/role/icon.ts +2 -2
- package/src/utils/role/merge.ts +2 -2
- package/src/utils/role/plugins.ts +1 -1
- package/src/utils/session/sessionFactory.ts +2 -2
- package/src/utils/session/sessionHelpers.ts +2 -2
- package/src/utils/tools/dedup.ts +4 -4
- package/src/utils/tools/result.ts +3 -3
- package/src/utils/types.ts +2 -2
|
@@ -26,14 +26,14 @@ interface SessionMeta {
|
|
|
26
26
|
origin?: SessionOrigin;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
async function readSessionMeta(__chatDir: string,
|
|
29
|
+
async function readSessionMeta(__chatDir: string, sessionId: string): Promise<SessionMeta | null> {
|
|
30
30
|
// Try new-style .json meta first
|
|
31
|
-
const meta = await readSessionMetaIO(
|
|
31
|
+
const meta = await readSessionMetaIO(sessionId);
|
|
32
32
|
if (meta?.roleId && meta?.startedAt) {
|
|
33
33
|
return meta as SessionMeta;
|
|
34
34
|
}
|
|
35
35
|
// Legacy: read first line of .jsonl
|
|
36
|
-
const jsonl = await readSessionJsonl(
|
|
36
|
+
const jsonl = await readSessionJsonl(sessionId);
|
|
37
37
|
if (jsonl) {
|
|
38
38
|
const first = jsonl.split("\n").find(Boolean);
|
|
39
39
|
if (first) {
|
|
@@ -102,19 +102,19 @@ const WINDOW_MS = env.sessionsListWindowDays * ONE_DAY_MS;
|
|
|
102
102
|
export async function loadAllSessions(): Promise<{ summary: SessionSummary; changeMs: number }[]> {
|
|
103
103
|
const chatDir = WORKSPACE_PATHS.chat;
|
|
104
104
|
const manifest = await readManifest(workspacePath);
|
|
105
|
-
const indexById = new Map<string, ChatIndexEntry>(manifest.entries.map((
|
|
105
|
+
const indexById = new Map<string, ChatIndexEntry>(manifest.entries.map((entry) => [entry.id, entry]));
|
|
106
106
|
const cutoff = WINDOW_MS > 0 ? Date.now() - WINDOW_MS : 0;
|
|
107
107
|
|
|
108
|
-
const files = (await readdir(chatDir)).filter((
|
|
108
|
+
const files = (await readdir(chatDir)).filter((fileName) => fileName.endsWith(".jsonl"));
|
|
109
109
|
const rows = await Promise.all(
|
|
110
110
|
files.map(async (file) => {
|
|
111
|
-
const
|
|
111
|
+
const sessionId = file.replace(".jsonl", "");
|
|
112
112
|
try {
|
|
113
113
|
// stat only — no readFile on .jsonl content
|
|
114
|
-
const fileStat = await stat(sessionJsonlAbsPath(
|
|
114
|
+
const fileStat = await stat(sessionJsonlAbsPath(sessionId));
|
|
115
115
|
if (cutoff > 0 && fileStat.mtimeMs < cutoff) return null;
|
|
116
116
|
|
|
117
|
-
const meta = await readSessionMeta(chatDir,
|
|
117
|
+
const meta = await readSessionMeta(chatDir, sessionId);
|
|
118
118
|
if (!meta) return null;
|
|
119
119
|
|
|
120
120
|
// The meta sidecar bumps its mtime on hasUnread / origin
|
|
@@ -122,20 +122,20 @@ export async function loadAllSessions(): Promise<{ summary: SessionSummary; chan
|
|
|
122
122
|
// pick up drains of background generations (which only touch
|
|
123
123
|
// meta, not the jsonl). Missing stat (brand-new session
|
|
124
124
|
// before its first meta write) contributes 0.
|
|
125
|
-
const metaMtimeMs = await stat(sessionMetaAbsPath(
|
|
126
|
-
.then((
|
|
125
|
+
const metaMtimeMs = await stat(sessionMetaAbsPath(sessionId))
|
|
126
|
+
.then((stats) => stats.mtimeMs)
|
|
127
127
|
.catch(() => 0);
|
|
128
128
|
|
|
129
|
-
const indexEntry = indexById.get(
|
|
129
|
+
const indexEntry = indexById.get(sessionId);
|
|
130
130
|
// Prefer AI title → meta.firstUserMessage → empty.
|
|
131
131
|
// `summary` and `keywords` are spread conditionally
|
|
132
132
|
// to respect the server tsconfig's
|
|
133
133
|
// exactOptionalPropertyTypes.
|
|
134
134
|
const preview = indexEntry?.title ?? meta.firstUserMessage ?? "";
|
|
135
135
|
|
|
136
|
-
const live = getSession(
|
|
136
|
+
const live = getSession(sessionId);
|
|
137
137
|
const summary: SessionSummary = {
|
|
138
|
-
id,
|
|
138
|
+
id: sessionId,
|
|
139
139
|
roleId: meta.roleId,
|
|
140
140
|
startedAt: meta.startedAt,
|
|
141
141
|
updatedAt: new Date(fileStat.mtimeMs).toISOString(),
|
|
@@ -161,7 +161,7 @@ export async function loadAllSessions(): Promise<{ summary: SessionSummary; chan
|
|
|
161
161
|
}
|
|
162
162
|
}),
|
|
163
163
|
);
|
|
164
|
-
return rows.filter((
|
|
164
|
+
return rows.filter((row): row is { summary: SessionSummary; changeMs: number } => row !== null);
|
|
165
165
|
}
|
|
166
166
|
|
|
167
167
|
router.get(API_ROUTES.sessions.list, async (req: Request<object, SessionsResponse, object, SessionsQuery>, res: Response<SessionsResponse>) => {
|
|
@@ -173,15 +173,15 @@ router.get(API_ROUTES.sessions.list, async (req: Request<object, SessionsRespons
|
|
|
173
173
|
// of whether it's in the diff. Echoing the same cursor back on an
|
|
174
174
|
// empty diff (nothing changed since `?since=`) is fine; the
|
|
175
175
|
// client no-ops.
|
|
176
|
-
const maxChangeMs = rows.reduce((acc,
|
|
176
|
+
const maxChangeMs = rows.reduce((acc, row) => Math.max(acc, row.changeMs), 0);
|
|
177
177
|
|
|
178
|
-
const filtered = sinceMs > 0 ? rows.filter((
|
|
178
|
+
const filtered = sinceMs > 0 ? rows.filter((row) => row.changeMs > sinceMs) : rows;
|
|
179
179
|
|
|
180
|
-
const sessions = filtered.map((
|
|
181
|
-
sessions.sort((
|
|
182
|
-
const byUpdated = new Date(
|
|
180
|
+
const sessions = filtered.map((row) => row.summary);
|
|
181
|
+
sessions.sort((leftSession, rightSession) => {
|
|
182
|
+
const byUpdated = new Date(rightSession.updatedAt).getTime() - new Date(leftSession.updatedAt).getTime();
|
|
183
183
|
if (byUpdated !== 0) return byUpdated;
|
|
184
|
-
return new Date(
|
|
184
|
+
return new Date(rightSession.startedAt).getTime() - new Date(leftSession.startedAt).getTime();
|
|
185
185
|
});
|
|
186
186
|
|
|
187
187
|
res.json({
|
|
@@ -207,13 +207,13 @@ interface SessionErrorResponse {
|
|
|
207
207
|
}
|
|
208
208
|
|
|
209
209
|
router.get(API_ROUTES.sessions.detail, async (req: Request<SessionIdParams>, res: Response<unknown[] | SessionErrorResponse>) => {
|
|
210
|
-
const { id } = req.params;
|
|
210
|
+
const { id: sessionId } = req.params;
|
|
211
211
|
const chatDir = WORKSPACE_PATHS.chat;
|
|
212
212
|
try {
|
|
213
|
-
const meta = await readSessionMeta(chatDir,
|
|
214
|
-
const content = await readSessionJsonl(
|
|
213
|
+
const meta = await readSessionMeta(chatDir, sessionId);
|
|
214
|
+
const content = await readSessionJsonl(sessionId);
|
|
215
215
|
if (!content) {
|
|
216
|
-
notFound(res, `Session ${
|
|
216
|
+
notFound(res, `Session ${sessionId} not found`);
|
|
217
217
|
return;
|
|
218
218
|
}
|
|
219
219
|
const entries = (
|
|
@@ -79,8 +79,8 @@ function uniqueId(base: string, existingIds: ReadonlySet<string>): string {
|
|
|
79
79
|
|
|
80
80
|
// ── Validation helpers ────────────────────────────────────────────
|
|
81
81
|
|
|
82
|
-
function findColumn(columns: StatusColumn[],
|
|
83
|
-
return columns.find((column) => column.id ===
|
|
82
|
+
function findColumn(columns: StatusColumn[], columnId: string): StatusColumn | undefined {
|
|
83
|
+
return columns.find((column) => column.id === columnId);
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function ensureColumnsValid(columns: StatusColumn[]): StatusColumn[] {
|
|
@@ -161,11 +161,11 @@ export function defaultStatusId(columns: StatusColumn[]): string {
|
|
|
161
161
|
// to sync at that point.
|
|
162
162
|
export function resyncDoneMembership(items: TodoItem[], newDoneId: string): { items: TodoItem[]; changed: boolean } {
|
|
163
163
|
let changed = false;
|
|
164
|
-
const next = items.map((
|
|
165
|
-
const shouldBeDone =
|
|
166
|
-
if (
|
|
164
|
+
const next = items.map((item): TodoItem => {
|
|
165
|
+
const shouldBeDone = item.status === newDoneId;
|
|
166
|
+
if (item.completed === shouldBeDone) return item;
|
|
167
167
|
changed = true;
|
|
168
|
-
return { ...
|
|
168
|
+
return { ...item, completed: shouldBeDone };
|
|
169
169
|
});
|
|
170
170
|
return { items: next, changed };
|
|
171
171
|
}
|
|
@@ -178,7 +178,7 @@ export function resyncDoneMembership(items: TodoItem[], newDoneId: string): { it
|
|
|
178
178
|
function rebuildColumnOrder(items: TodoItem[], columnId: string): TodoItem[] {
|
|
179
179
|
const inColumn = items.filter((item) => item.status === columnId).sort((left, right) => (left.order ?? 0) - (right.order ?? 0));
|
|
180
180
|
const newOrders = new Map<string, number>();
|
|
181
|
-
inColumn.forEach((item,
|
|
181
|
+
inColumn.forEach((item, index) => newOrders.set(item.id, (index + 1) * ORDER_STEP));
|
|
182
182
|
return items.map((item): TodoItem => {
|
|
183
183
|
const newOrder = newOrders.get(item.id);
|
|
184
184
|
if (newOrder === undefined) return item;
|
|
@@ -200,23 +200,23 @@ export function handleAddColumn(columns: StatusColumn[], items: TodoItem[], inpu
|
|
|
200
200
|
return { kind: "error", status: 400, error: "label required" };
|
|
201
201
|
}
|
|
202
202
|
const baseId = slugify(input.label);
|
|
203
|
-
const
|
|
204
|
-
const
|
|
205
|
-
if (input.isDone === true)
|
|
203
|
+
const columnId = uniqueId(baseId, new Set(columns.map((column) => column.id)));
|
|
204
|
+
const columnToAdd: StatusColumn = { id: columnId, label: input.label.trim() };
|
|
205
|
+
if (input.isDone === true) columnToAdd.isDone = true;
|
|
206
206
|
// If the new column is flagged done, demote any existing done
|
|
207
207
|
// columns (only one is allowed at a time) and resync items so the
|
|
208
208
|
// old done column's items are no longer marked completed. The new
|
|
209
209
|
// column itself is empty so there's nothing on its side to sync.
|
|
210
210
|
if (input.isDone === true) {
|
|
211
|
-
const nextColumns = [...columns.map((column) => ({ ...column, isDone: false })),
|
|
212
|
-
const { items: nextItems, changed } = resyncDoneMembership(items,
|
|
211
|
+
const nextColumns = [...columns.map((column) => ({ ...column, isDone: false })), columnToAdd];
|
|
212
|
+
const { items: nextItems, changed } = resyncDoneMembership(items, columnId);
|
|
213
213
|
return {
|
|
214
214
|
kind: "success",
|
|
215
215
|
columns: nextColumns,
|
|
216
216
|
...(changed ? { items: nextItems } : {}),
|
|
217
217
|
};
|
|
218
218
|
}
|
|
219
|
-
return { kind: "success", columns: [...columns,
|
|
219
|
+
return { kind: "success", columns: [...columns, columnToAdd] };
|
|
220
220
|
}
|
|
221
221
|
|
|
222
222
|
export interface PatchColumnInput {
|
|
@@ -224,28 +224,28 @@ export interface PatchColumnInput {
|
|
|
224
224
|
isDone?: boolean;
|
|
225
225
|
}
|
|
226
226
|
|
|
227
|
-
export function handlePatchColumn(columns: StatusColumn[],
|
|
228
|
-
const target = findColumn(columns,
|
|
227
|
+
export function handlePatchColumn(columns: StatusColumn[], columnId: string, input: PatchColumnInput, items: TodoItem[]): ColumnsActionResult {
|
|
228
|
+
const target = findColumn(columns, columnId);
|
|
229
229
|
if (!target) {
|
|
230
|
-
return { kind: "error", status: 404, error: `column not found: ${
|
|
230
|
+
return { kind: "error", status: 404, error: `column not found: ${columnId}` };
|
|
231
231
|
}
|
|
232
232
|
const patched: StatusColumn = { id: target.id, label: target.label };
|
|
233
233
|
if (target.isDone) patched.isDone = true;
|
|
234
234
|
if (typeof input.label === "string" && input.label.trim().length > 0) {
|
|
235
235
|
patched.label = input.label.trim();
|
|
236
236
|
}
|
|
237
|
-
let nextColumns = columns.map((column) => (column.id ===
|
|
237
|
+
let nextColumns = columns.map((column) => (column.id === columnId ? patched : column));
|
|
238
238
|
// Toggling done flag is non-trivial: only one column may be done.
|
|
239
239
|
let itemsChanged = false;
|
|
240
240
|
let nextItems = items;
|
|
241
241
|
if (input.isDone === true && !target.isDone) {
|
|
242
242
|
// Promote this column to done; demote everyone else.
|
|
243
|
-
nextColumns = nextColumns.map((column) => (column.id ===
|
|
243
|
+
nextColumns = nextColumns.map((column) => (column.id === columnId ? { ...column, isDone: true } : { id: column.id, label: column.label }));
|
|
244
244
|
// Resync `completed` across all items: the new done column's
|
|
245
245
|
// items become true, the old done column's items become false.
|
|
246
246
|
// Doing this with the helper rather than a one-sided pass means
|
|
247
247
|
// both ends of the swap stay consistent.
|
|
248
|
-
const synced = resyncDoneMembership(items,
|
|
248
|
+
const synced = resyncDoneMembership(items, columnId);
|
|
249
249
|
nextItems = synced.items;
|
|
250
250
|
itemsChanged = synced.changed;
|
|
251
251
|
} else if (input.isDone === false && target.isDone) {
|
|
@@ -263,7 +263,7 @@ export function handlePatchColumn(columns: StatusColumn[], id: string, input: Pa
|
|
|
263
263
|
};
|
|
264
264
|
}
|
|
265
265
|
|
|
266
|
-
export function handleDeleteColumn(columns: StatusColumn[],
|
|
266
|
+
export function handleDeleteColumn(columns: StatusColumn[], columnId: string, items: TodoItem[]): ColumnsActionResult {
|
|
267
267
|
if (columns.length <= 1) {
|
|
268
268
|
return {
|
|
269
269
|
kind: "error",
|
|
@@ -271,11 +271,11 @@ export function handleDeleteColumn(columns: StatusColumn[], id: string, items: T
|
|
|
271
271
|
error: "cannot delete the last remaining column",
|
|
272
272
|
};
|
|
273
273
|
}
|
|
274
|
-
const target = findColumn(columns,
|
|
274
|
+
const target = findColumn(columns, columnId);
|
|
275
275
|
if (!target) {
|
|
276
|
-
return { kind: "error", status: 404, error: `column not found: ${
|
|
276
|
+
return { kind: "error", status: 404, error: `column not found: ${columnId}` };
|
|
277
277
|
}
|
|
278
|
-
const remaining = columns.filter((column) => column.id !==
|
|
278
|
+
const remaining = columns.filter((column) => column.id !== columnId);
|
|
279
279
|
// If we just removed the done column, promote the new last column.
|
|
280
280
|
let nextColumns = remaining;
|
|
281
281
|
if (target.isDone) {
|
|
@@ -286,10 +286,10 @@ export function handleDeleteColumn(columns: StatusColumn[], id: string, items: T
|
|
|
286
286
|
// deleted column was done; otherwise to the new default open column.
|
|
287
287
|
const refugeId = target.isDone ? newDoneId : defaultStatusId(nextColumns);
|
|
288
288
|
let itemsChanged = false;
|
|
289
|
-
let nextItems = items.map((
|
|
290
|
-
if (
|
|
289
|
+
let nextItems = items.map((item): TodoItem => {
|
|
290
|
+
if (item.status !== columnId) return item;
|
|
291
291
|
itemsChanged = true;
|
|
292
|
-
return { ...
|
|
292
|
+
return { ...item, status: refugeId };
|
|
293
293
|
});
|
|
294
294
|
if (itemsChanged) {
|
|
295
295
|
// The refuge column might have already had items in it; the ones
|
|
@@ -331,17 +331,17 @@ export function handleReorderColumns(columns: StatusColumn[], ids: string[]): Co
|
|
|
331
331
|
}
|
|
332
332
|
const known = new Set(columns.map((column) => column.id));
|
|
333
333
|
const seen = new Set<string>();
|
|
334
|
-
for (const
|
|
335
|
-
if (!known.has(
|
|
334
|
+
for (const columnId of ids) {
|
|
335
|
+
if (!known.has(columnId) || seen.has(columnId)) {
|
|
336
336
|
return {
|
|
337
337
|
kind: "error",
|
|
338
338
|
status: 400,
|
|
339
339
|
error: "ids must contain every existing column id exactly once",
|
|
340
340
|
};
|
|
341
341
|
}
|
|
342
|
-
seen.add(
|
|
342
|
+
seen.add(columnId);
|
|
343
343
|
}
|
|
344
344
|
const byId = new Map(columns.map((column) => [column.id, column]));
|
|
345
|
-
const next = ids.map((
|
|
345
|
+
const next = ids.map((columnId) => byId.get(columnId)!);
|
|
346
346
|
return { kind: "success", columns: next };
|
|
347
347
|
}
|
|
@@ -46,7 +46,7 @@ function parseTableRow(trimmed: string): WikiPageEntry | null {
|
|
|
46
46
|
const cols = trimmed
|
|
47
47
|
.split("|")
|
|
48
48
|
.slice(1, -1)
|
|
49
|
-
.map((
|
|
49
|
+
.map((column) => column.trim().replace(/^`|`$/g, ""));
|
|
50
50
|
if (cols.length < 2) return null;
|
|
51
51
|
const slug = cols[0];
|
|
52
52
|
const title = cols[1] || slug;
|
|
@@ -70,11 +70,11 @@ export function extractSlugFromBulletHref(rawHref: string): string {
|
|
|
70
70
|
}
|
|
71
71
|
|
|
72
72
|
function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
73
|
-
const
|
|
74
|
-
if (!
|
|
75
|
-
const title =
|
|
76
|
-
const href =
|
|
77
|
-
const desc =
|
|
73
|
+
const match = BULLET_LINK_PATTERN.exec(trimmed);
|
|
74
|
+
if (!match) return null;
|
|
75
|
+
const title = match[1].trim();
|
|
76
|
+
const href = match[2] ?? "";
|
|
77
|
+
const desc = match[3]?.trim() ?? "";
|
|
78
78
|
// Prefer the slug embedded in the href so non-ASCII titles keep
|
|
79
79
|
// a navigable slug. Fall back to slugifying the title only when
|
|
80
80
|
// the href has no recognisable slug (rare — usually means the
|
|
@@ -84,10 +84,10 @@ function parseBulletLinkRow(trimmed: string): WikiPageEntry | null {
|
|
|
84
84
|
}
|
|
85
85
|
|
|
86
86
|
function parseBulletWikiLinkRow(trimmed: string): WikiPageEntry | null {
|
|
87
|
-
const
|
|
88
|
-
if (!
|
|
89
|
-
const title =
|
|
90
|
-
const desc =
|
|
87
|
+
const match = BULLET_WIKI_LINK_PATTERN.exec(trimmed);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const title = match[1].trim();
|
|
90
|
+
const desc = match[2]?.trim() ?? "";
|
|
91
91
|
return { title, slug: wikiSlugify(title), description: desc };
|
|
92
92
|
}
|
|
93
93
|
|
|
@@ -276,7 +276,7 @@ export function findMissingFiles(pageEntries: readonly WikiPageEntry[], fileSlug
|
|
|
276
276
|
|
|
277
277
|
export function findBrokenLinksInPage(fileName: string, content: string, fileSlugs: ReadonlySet<string>): string[] {
|
|
278
278
|
const issues: string[] = [];
|
|
279
|
-
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((
|
|
279
|
+
const wikiLinks = [...content.matchAll(WIKI_LINK_PATTERN)].map((match) => match[1]);
|
|
280
280
|
for (const link of wikiLinks) {
|
|
281
281
|
const linkSlug = wikiSlugify(link);
|
|
282
282
|
if (!fileSlugs.has(linkSlug)) {
|
|
@@ -302,7 +302,7 @@ async function collectLintIssues(): Promise<string[]> {
|
|
|
302
302
|
}
|
|
303
303
|
const indexContent = readFileOrEmpty(indexFile());
|
|
304
304
|
const pageEntries = parseIndexEntries(indexContent);
|
|
305
|
-
const indexedSlugs = new Set(pageEntries.map((
|
|
305
|
+
const indexedSlugs = new Set(pageEntries.map((entry) => entry.slug));
|
|
306
306
|
const pageFiles = [...slugs.values()];
|
|
307
307
|
const fileSlugs = new Set(slugs.keys());
|
|
308
308
|
|
|
@@ -312,8 +312,8 @@ async function collectLintIssues(): Promise<string[]> {
|
|
|
312
312
|
// Parallel read: N small markdown files, ~50 KB each. Bounded by
|
|
313
313
|
// the number of wiki pages, not by CPU.
|
|
314
314
|
const contents = await Promise.all(
|
|
315
|
-
pageFiles.map(async (
|
|
316
|
-
const content = await readTextSafe(path.join(dir,
|
|
315
|
+
pageFiles.map(async (fileName) => {
|
|
316
|
+
const content = await readTextSafe(path.join(dir, fileName));
|
|
317
317
|
return content ?? "";
|
|
318
318
|
}),
|
|
319
319
|
);
|
|
@@ -52,16 +52,16 @@ function logsDir(root = workspacePath): string {
|
|
|
52
52
|
// ── I/O deps (real filesystem) ───────────────────────────────────
|
|
53
53
|
|
|
54
54
|
const stateDeps: StateDeps = {
|
|
55
|
-
readFile: (
|
|
56
|
-
writeFileAtomic: (
|
|
55
|
+
readFile: (filePath: string) => readFile(filePath, "utf-8"),
|
|
56
|
+
writeFileAtomic: (filePath: string, content: string) => writeFileAtomic(filePath, content),
|
|
57
57
|
exists: existsSync,
|
|
58
58
|
};
|
|
59
59
|
|
|
60
60
|
const logDeps: LogDeps = {
|
|
61
|
-
appendFile: (
|
|
62
|
-
readFile: (
|
|
61
|
+
appendFile: (filePath: string, content: string) => appendFile(filePath, content),
|
|
62
|
+
readFile: (filePath: string) => readFile(filePath, "utf-8"),
|
|
63
63
|
exists: existsSync,
|
|
64
|
-
ensureDir: (
|
|
64
|
+
ensureDir: (directoryPath: string) => mkdir(directoryPath, { recursive: true }).then(() => {}),
|
|
65
65
|
};
|
|
66
66
|
|
|
67
67
|
// ── System task registry ─────────────────────────────────────────
|
|
@@ -95,11 +95,11 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
|
|
|
95
95
|
taskManagerRef = taskManager;
|
|
96
96
|
|
|
97
97
|
// Run catch-up
|
|
98
|
-
const catchUpTasks: CatchUpTask[] = tasks.map((
|
|
99
|
-
id:
|
|
100
|
-
name:
|
|
101
|
-
schedule: toCoreSchedule(
|
|
102
|
-
missedRunPolicy:
|
|
98
|
+
const catchUpTasks: CatchUpTask[] = tasks.map((taskDef) => ({
|
|
99
|
+
id: taskDef.id,
|
|
100
|
+
name: taskDef.name,
|
|
101
|
+
schedule: toCoreSchedule(taskDef.schedule),
|
|
102
|
+
missedRunPolicy: taskDef.missedRunPolicy,
|
|
103
103
|
enabled: true,
|
|
104
104
|
}));
|
|
105
105
|
const plan = computeCatchUpPlan(catchUpTasks, stateMap, Date.now());
|
|
@@ -117,7 +117,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
|
|
|
117
117
|
runs: plan.runs.length,
|
|
118
118
|
});
|
|
119
119
|
for (const run of plan.runs) {
|
|
120
|
-
const task = tasks.find((
|
|
120
|
+
const task = tasks.find((taskDef) => taskDef.id === run.taskId);
|
|
121
121
|
if (!task) continue;
|
|
122
122
|
await executeAndLog(task, run.context.scheduledFor, TASK_TRIGGERS.catchUp);
|
|
123
123
|
}
|
|
@@ -137,7 +137,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
|
|
|
137
137
|
}
|
|
138
138
|
|
|
139
139
|
log.info("scheduler", "initialized", {
|
|
140
|
-
tasks: tasks.map((
|
|
140
|
+
tasks: tasks.map((taskDef) => taskDef.id),
|
|
141
141
|
stateEntries: stateMap.size,
|
|
142
142
|
});
|
|
143
143
|
}
|
|
@@ -146,7 +146,7 @@ export async function initScheduler(taskManager: ITaskManager, tasks: SystemTask
|
|
|
146
146
|
* Updates the in-memory task definition, the task-manager, and
|
|
147
147
|
* recalculates nextScheduledAt in persisted state. */
|
|
148
148
|
export async function applyScheduleOverride(taskId: string, schedule: SystemTaskDef["schedule"]): Promise<boolean> {
|
|
149
|
-
const task = systemTasks.find((
|
|
149
|
+
const task = systemTasks.find((taskDef) => taskDef.id === taskId);
|
|
150
150
|
if (!task || !taskManagerRef) return false;
|
|
151
151
|
if (!taskManagerRef.updateSchedule(taskId, schedule)) return false;
|
|
152
152
|
task.schedule = schedule;
|
|
@@ -172,13 +172,13 @@ export function getSchedulerTasks(): Array<{
|
|
|
172
172
|
missedRunPolicy: string;
|
|
173
173
|
state: TaskExecutionState;
|
|
174
174
|
}> {
|
|
175
|
-
return systemTasks.map((
|
|
176
|
-
id:
|
|
177
|
-
name:
|
|
178
|
-
description:
|
|
179
|
-
schedule:
|
|
180
|
-
missedRunPolicy:
|
|
181
|
-
state: stateMap.get(
|
|
175
|
+
return systemTasks.map((taskDef) => ({
|
|
176
|
+
id: taskDef.id,
|
|
177
|
+
name: taskDef.name,
|
|
178
|
+
description: taskDef.description,
|
|
179
|
+
schedule: taskDef.schedule,
|
|
180
|
+
missedRunPolicy: taskDef.missedRunPolicy,
|
|
181
|
+
state: stateMap.get(taskDef.id) ?? emptyState(taskDef.id),
|
|
182
182
|
}));
|
|
183
183
|
}
|
|
184
184
|
|
|
@@ -65,8 +65,8 @@ const storelessPending = new Map<string, Set<string>>();
|
|
|
65
65
|
let pubsub: IPubSub | null = null;
|
|
66
66
|
let evictionTimer: ReturnType<typeof setInterval> | null = null;
|
|
67
67
|
|
|
68
|
-
export function initSessionStore(
|
|
69
|
-
pubsub =
|
|
68
|
+
export function initSessionStore(pubSubInstance: IPubSub): void {
|
|
69
|
+
pubsub = pubSubInstance;
|
|
70
70
|
if (evictionTimer) clearInterval(evictionTimer);
|
|
71
71
|
evictionTimer = setInterval(evictIdleSessions, EVICTION_CHECK_INTERVAL_MS);
|
|
72
72
|
}
|
|
@@ -288,8 +288,8 @@ interface GenerationPayload {
|
|
|
288
288
|
|
|
289
289
|
const GENERATION_KIND_VALUES: ReadonlySet<string> = new Set(Object.values(GENERATION_KINDS));
|
|
290
290
|
|
|
291
|
-
function isGenerationKind(
|
|
292
|
-
return typeof
|
|
291
|
+
function isGenerationKind(value: unknown): value is GenerationKind {
|
|
292
|
+
return typeof value === "string" && GENERATION_KIND_VALUES.has(value);
|
|
293
293
|
}
|
|
294
294
|
|
|
295
295
|
/**
|
|
@@ -314,7 +314,7 @@ function applyEventToSession(session: ServerSession, type: string, event: Record
|
|
|
314
314
|
timestamp: Date.now(),
|
|
315
315
|
});
|
|
316
316
|
} else if (type === EVENT_TYPES.toolCallResult) {
|
|
317
|
-
const entry = session.toolCallHistory.find((
|
|
317
|
+
const entry = session.toolCallHistory.find((historyEntry) => historyEntry.toolUseId === event.toolUseId);
|
|
318
318
|
if (entry) entry.result = event.content as string;
|
|
319
319
|
} else if (type === EVENT_TYPES.status) {
|
|
320
320
|
session.statusMessage = event.message as string;
|
|
@@ -404,8 +404,8 @@ export function getSessionImageData(chatSessionId: string): string | undefined {
|
|
|
404
404
|
|
|
405
405
|
export function getActiveSessionIds(): Set<string> {
|
|
406
406
|
const ids = new Set<string>();
|
|
407
|
-
for (const [
|
|
408
|
-
if (session.isRunning) ids.add(
|
|
407
|
+
for (const [chatSessionId, session] of store) {
|
|
408
|
+
if (session.isRunning) ids.add(chatSessionId);
|
|
409
409
|
}
|
|
410
410
|
return ids;
|
|
411
411
|
}
|
|
@@ -465,14 +465,14 @@ function notifySessionsChanged(): void {
|
|
|
465
465
|
|
|
466
466
|
function evictIdleSessions(): void {
|
|
467
467
|
const now = Date.now();
|
|
468
|
-
for (const [
|
|
468
|
+
for (const [chatSessionId, session] of store) {
|
|
469
469
|
if (session.isRunning) continue;
|
|
470
470
|
const age = now - new Date(session.updatedAt).getTime();
|
|
471
471
|
if (age > IDLE_EVICTION_MS) {
|
|
472
472
|
log.info("session-store", "evicting idle session", {
|
|
473
|
-
chatSessionId
|
|
473
|
+
chatSessionId,
|
|
474
474
|
});
|
|
475
|
-
removeSession(
|
|
475
|
+
removeSession(chatSessionId);
|
|
476
476
|
}
|
|
477
477
|
}
|
|
478
478
|
}
|
|
@@ -52,8 +52,8 @@ function isDue(now: Date, schedule: TaskSchedule, tickMs: number): boolean {
|
|
|
52
52
|
}
|
|
53
53
|
|
|
54
54
|
if (schedule.type === SCHEDULE_TYPES.daily) {
|
|
55
|
-
const [
|
|
56
|
-
const targetMs =
|
|
55
|
+
const [hours, minutes] = schedule.time.split(":").map(Number);
|
|
56
|
+
const targetMs = hours * ONE_HOUR_MS + minutes * ONE_MINUTE_MS;
|
|
57
57
|
const msSinceMidnight = now.getUTCHours() * ONE_HOUR_MS + now.getUTCMinutes() * ONE_MINUTE_MS + now.getUTCSeconds() * ONE_SECOND_MS;
|
|
58
58
|
const rounded = Math.floor(msSinceMidnight / tickMs) * tickMs;
|
|
59
59
|
return rounded === targetMs;
|
|
@@ -170,11 +170,11 @@ export function createTaskManager(options?: TaskManagerOptions): ITaskManager {
|
|
|
170
170
|
},
|
|
171
171
|
|
|
172
172
|
listTasks() {
|
|
173
|
-
return [...registry.values()].map((
|
|
174
|
-
id:
|
|
175
|
-
description:
|
|
176
|
-
schedule:
|
|
177
|
-
dependsOn:
|
|
173
|
+
return [...registry.values()].map((taskDef) => ({
|
|
174
|
+
id: taskDef.id,
|
|
175
|
+
description: taskDef.description,
|
|
176
|
+
schedule: taskDef.schedule,
|
|
177
|
+
dependsOn: taskDef.dependsOn,
|
|
178
178
|
}));
|
|
179
179
|
},
|
|
180
180
|
};
|
package/server/index.ts
CHANGED
|
@@ -157,13 +157,13 @@ app.use(configRoutes);
|
|
|
157
157
|
app.use(skillsRoutes);
|
|
158
158
|
async function listSessionsForBridge(opts: { limit: number; offset: number }) {
|
|
159
159
|
const rows = await loadAllSessions();
|
|
160
|
-
const sorted = rows.sort((
|
|
160
|
+
const sorted = rows.sort((leftSession, rightSession) => rightSession.changeMs - leftSession.changeMs);
|
|
161
161
|
const total = sorted.length;
|
|
162
|
-
const sessions = sorted.slice(opts.offset, opts.offset + opts.limit).map((
|
|
163
|
-
id:
|
|
164
|
-
roleId:
|
|
165
|
-
preview:
|
|
166
|
-
updatedAt:
|
|
162
|
+
const sessions = sorted.slice(opts.offset, opts.offset + opts.limit).map((row) => ({
|
|
163
|
+
id: row.summary.id,
|
|
164
|
+
roleId: row.summary.roleId,
|
|
165
|
+
preview: row.summary.preview,
|
|
166
|
+
updatedAt: row.summary.updatedAt,
|
|
167
167
|
}));
|
|
168
168
|
return { sessions, total };
|
|
169
169
|
}
|
|
@@ -275,8 +275,8 @@ async function ensureCredentialsAvailable(): Promise<void> {
|
|
|
275
275
|
|
|
276
276
|
if (process.platform === "darwin") {
|
|
277
277
|
const { refreshCredentials } = await import("./system/credentials.js");
|
|
278
|
-
const
|
|
279
|
-
if (
|
|
278
|
+
const refreshSucceeded = await refreshCredentials();
|
|
279
|
+
if (refreshSucceeded) return;
|
|
280
280
|
log.error("sandbox", "Failed to export credentials from macOS Keychain. Run `npm run sandbox:login` manually.");
|
|
281
281
|
process.exit(1);
|
|
282
282
|
}
|
|
@@ -310,14 +310,14 @@ async function setupSandbox(): Promise<boolean> {
|
|
|
310
310
|
|
|
311
311
|
function logMcpStatus(): void {
|
|
312
312
|
const enabledMcpTools = mcpTools.filter(isMcpToolEnabled);
|
|
313
|
-
const disabledMcpTools = mcpTools.filter((
|
|
313
|
+
const disabledMcpTools = mcpTools.filter((toolDef) => !isMcpToolEnabled(toolDef));
|
|
314
314
|
if (enabledMcpTools.length > 0) {
|
|
315
315
|
log.info("mcp", "Available", {
|
|
316
|
-
tools: enabledMcpTools.map((
|
|
316
|
+
tools: enabledMcpTools.map((toolDef) => toolDef.definition.name).join(", "),
|
|
317
317
|
});
|
|
318
318
|
}
|
|
319
319
|
if (disabledMcpTools.length > 0) {
|
|
320
|
-
const names = disabledMcpTools.map((
|
|
320
|
+
const names = disabledMcpTools.map((toolDef) => toolDef.definition.name + " (" + (toolDef.requiredEnv ?? []).join(", ") + ")").join(", ");
|
|
321
321
|
log.info("mcp", "Unavailable (missing env)", { tools: names });
|
|
322
322
|
}
|
|
323
323
|
}
|
|
@@ -424,24 +424,24 @@ function startRuntimeServices(httpServer: ReturnType<typeof app.listen>): void {
|
|
|
424
424
|
// are silently ignored — the hardcoded defaults above remain.
|
|
425
425
|
const overrides = loadSchedulerOverrides();
|
|
426
426
|
for (const task of systemTasks) {
|
|
427
|
-
const
|
|
428
|
-
if (!
|
|
429
|
-
if (task.schedule.type === SCHEDULE_TYPES.interval && typeof
|
|
427
|
+
const override = overrides[task.id];
|
|
428
|
+
if (!override) continue;
|
|
429
|
+
if (task.schedule.type === SCHEDULE_TYPES.interval && typeof override.intervalMs === "number" && override.intervalMs > 0) {
|
|
430
430
|
log.info("scheduler", "applying override", {
|
|
431
431
|
id: task.id,
|
|
432
|
-
intervalMs:
|
|
432
|
+
intervalMs: override.intervalMs,
|
|
433
433
|
});
|
|
434
434
|
task.schedule = {
|
|
435
435
|
type: SCHEDULE_TYPES.interval,
|
|
436
|
-
intervalMs:
|
|
436
|
+
intervalMs: override.intervalMs,
|
|
437
437
|
};
|
|
438
438
|
}
|
|
439
|
-
if (task.schedule.type === SCHEDULE_TYPES.daily && typeof
|
|
439
|
+
if (task.schedule.type === SCHEDULE_TYPES.daily && typeof override.time === "string" && UTC_HH_MM_RE.test(override.time)) {
|
|
440
440
|
log.info("scheduler", "applying override", {
|
|
441
441
|
id: task.id,
|
|
442
|
-
time:
|
|
442
|
+
time: override.time,
|
|
443
443
|
});
|
|
444
|
-
task.schedule = { type: SCHEDULE_TYPES.daily, time:
|
|
444
|
+
task.schedule = { type: SCHEDULE_TYPES.daily, time: override.time };
|
|
445
445
|
}
|
|
446
446
|
}
|
|
447
447
|
|