@polderlabs/bizar 5.0.0 → 5.0.2

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 (97) hide show
  1. package/bizar-dash/dist/assets/{icons-CFqu2M-c.js → icons-Bo0iH9EC.js} +166 -121
  2. package/bizar-dash/dist/assets/icons-Bo0iH9EC.js.map +1 -0
  3. package/bizar-dash/dist/assets/main-C1cpttnv.js +19 -0
  4. package/bizar-dash/dist/assets/main-C1cpttnv.js.map +1 -0
  5. package/bizar-dash/dist/assets/main-DTkNlLrw.css +1 -0
  6. package/bizar-dash/dist/assets/mobile-BtxQJftK.js +2 -0
  7. package/bizar-dash/dist/assets/mobile-BtxQJftK.js.map +1 -0
  8. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js → mobile-DD-FZrTC.js} +1 -1
  9. package/bizar-dash/dist/assets/{mobile-DHXXbn1A.js.map → mobile-DD-FZrTC.js.map} +1 -1
  10. package/bizar-dash/dist/index.html +4 -4
  11. package/bizar-dash/dist/mobile.html +3 -3
  12. package/bizar-dash/node_modules/.package-lock.json +6 -0
  13. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  14. package/bizar-dash/package-lock.json +6 -0
  15. package/bizar-dash/src/server/api.mjs +7 -0
  16. package/bizar-dash/src/server/diagnostics-store.mjs +452 -2
  17. package/bizar-dash/src/server/memory-store.mjs +46 -0
  18. package/bizar-dash/src/server/providers-store.mjs +11 -4
  19. package/bizar-dash/src/server/routes/_shared.mjs +2 -2
  20. package/bizar-dash/src/server/routes/doctor.mjs +71 -0
  21. package/bizar-dash/src/server/routes/env-vars.mjs +67 -1
  22. package/bizar-dash/src/server/routes/memory.mjs +3 -0
  23. package/bizar-dash/src/server/routes/opencode-session-detail.mjs +61 -42
  24. package/bizar-dash/src/server/routes/schedules.mjs +55 -0
  25. package/bizar-dash/src/server/serve-info.mjs +172 -0
  26. package/bizar-dash/src/web/App.tsx +48 -11
  27. package/bizar-dash/src/web/components/AutosaveField.tsx +50 -0
  28. package/bizar-dash/src/web/components/DoctorPanel.tsx +160 -0
  29. package/bizar-dash/src/web/components/EnvVarManager.tsx +245 -62
  30. package/bizar-dash/src/web/components/ScheduleTemplateCard.tsx +71 -0
  31. package/bizar-dash/src/web/components/SettingsNav.tsx +101 -0
  32. package/bizar-dash/src/web/components/Sidebar.tsx +25 -1
  33. package/bizar-dash/src/web/components/StatusBadge.tsx +33 -2
  34. package/bizar-dash/src/web/components/Topbar.tsx +11 -0
  35. package/bizar-dash/src/web/components/chat/ChatInfoPanel.tsx +64 -0
  36. package/bizar-dash/src/web/components/chat/Composer.tsx +1 -1
  37. package/bizar-dash/src/web/components/chat/useChat.ts +118 -1
  38. package/bizar-dash/src/web/hooks/useAutosave.ts +107 -0
  39. package/bizar-dash/src/web/lib/types.ts +134 -0
  40. package/bizar-dash/src/web/styles/chat.css +8 -5
  41. package/bizar-dash/src/web/styles/main.css +77 -2
  42. package/bizar-dash/src/web/styles/settings.css +265 -0
  43. package/bizar-dash/src/web/views/Chat.tsx +15 -1
  44. package/bizar-dash/src/web/views/Doctor.tsx +317 -0
  45. package/bizar-dash/src/web/views/MiniMaxUsage.tsx +1 -0
  46. package/bizar-dash/src/web/views/Schedules.tsx +94 -12
  47. package/bizar-dash/src/web/views/Settings.tsx +76 -46
  48. package/bizar-dash/src/web/views/memory/ConfigPanel.tsx +114 -133
  49. package/bizar-dash/src/web/views/settings/AgentSection.tsx +23 -12
  50. package/bizar-dash/src/web/views/settings/EnvVarsSection.tsx +5 -6
  51. package/bizar-dash/src/web/views/settings/GeneralSection.tsx +38 -15
  52. package/bizar-dash/src/web/views/settings/MemorySection.tsx +92 -8
  53. package/bizar-dash/tests/autosave.test.tsx +276 -0
  54. package/bizar-dash/tests/chat-composer.test.tsx +140 -0
  55. package/bizar-dash/tests/components/doctor-panel.test.tsx +105 -0
  56. package/bizar-dash/tests/diagnostics-store.test.mjs +206 -0
  57. package/bizar-dash/tests/env-vars-extended.test.mjs +190 -0
  58. package/bizar-dash/tests/layout-spacing.test.mjs +101 -0
  59. package/bizar-dash/tests/memory-default-vault.test.mjs +98 -0
  60. package/bizar-dash/tests/minimax-bar.test.tsx +113 -0
  61. package/bizar-dash/tests/minimax-models.test.mjs +123 -0
  62. package/bizar-dash/tests/opencode-sessions-detail.test.mjs +158 -0
  63. package/bizar-dash/tests/routes-doctor.test.mjs +159 -0
  64. package/bizar-dash/tests/schedules-templates.test.mjs +144 -0
  65. package/bizar-dash/tests/settings-layout.test.tsx +129 -0
  66. package/bizar-dash/tests/settings-mode-wiring.test.tsx +151 -0
  67. package/bizar-dash/tests/settings-nav.test.tsx +126 -0
  68. package/cli/commands/dash.mjs +5 -1
  69. package/cli/commands/minimax.mjs +5 -2
  70. package/cli/tests/minimax-cli.test.mjs +79 -0
  71. package/config/agents/frigg.md +1 -1
  72. package/config/agents/heimdall.md +1 -1
  73. package/config/agents/mimir.md +1 -1
  74. package/config/agents/quick.md +1 -1
  75. package/config/agents/semble-search.md +1 -1
  76. package/config/agents/vor.md +1 -1
  77. package/config/opencode.json +28 -21
  78. package/config/opencode.json.template +8 -1
  79. package/package.json +1 -1
  80. package/plugins/bizar/index.ts +77 -0
  81. package/plugins/bizar/src/compaction.d.mts +48 -0
  82. package/plugins/bizar/src/compaction.mjs +192 -0
  83. package/plugins/bizar/tests/compaction.test.ts +264 -0
  84. package/templates/schedules/daily-backup.json +12 -0
  85. package/templates/schedules/daily-cleanup.json +12 -0
  86. package/templates/schedules/hourly-health-check.json +12 -0
  87. package/templates/schedules/webhook-on-push.json +13 -0
  88. package/templates/schedules/weekly-digest.json +13 -0
  89. package/bizar-dash/dist/assets/icons-CFqu2M-c.js.map +0 -1
  90. package/bizar-dash/dist/assets/main-Dl8yY5_H.js +0 -16
  91. package/bizar-dash/dist/assets/main-Dl8yY5_H.js.map +0 -1
  92. package/bizar-dash/dist/assets/main-ZAfGKENE.css +0 -1
  93. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js +0 -2
  94. package/bizar-dash/dist/assets/mobile-C2gysFOZ.js.map +0 -1
  95. package/bizar-dash/src/web/views/settings/BackupSection.tsx +0 -16
  96. package/bizar-dash/src/web/views/settings/ProvidersSection.tsx +0 -16
  97. package/bizar-dash/src/web/views/settings/SkillsSection.tsx +0 -16
@@ -131,8 +131,8 @@ export const DEFAULT_SETTINGS = {
131
131
  },
132
132
  systemLlm: {
133
133
  enabled: true,
134
- provider: 'opencode',
135
- model: 'opencode/deepseek-v4-flash-free',
134
+ provider: 'minimax',
135
+ model: 'minimax/MiniMax-M2.7',
136
136
  },
137
137
  // v4.5.0 — MiniMax Token Plan integration. `apiKey` is the
138
138
  // user's Subscription Key from
@@ -0,0 +1,71 @@
1
+ /**
2
+ * src/server/routes/doctor.mjs
3
+ *
4
+ * v6.0.0 — Doctor REST surface for the dashboard Doctor page.
5
+ *
6
+ * GET /api/doctor — full system snapshot (collectDiagnostics)
7
+ * GET /api/doctor/health — rolled-up status + per-group issues
8
+ * POST /api/doctor/check — run a single named check
9
+ *
10
+ * Mounted at /doctor by api.mjs. Existing /api/diagnostics/* routes
11
+ * (legacy v3) are untouched and keep using `diagnosticsStore.snapshot()`
12
+ * / `.health()`.
13
+ */
14
+ import { Router } from 'express';
15
+ import { wrap } from './_shared.mjs';
16
+ import { collectDiagnostics, runCheck, health } from '../diagnostics-store.mjs';
17
+ import { info } from '../logger.mjs';
18
+
19
+ /**
20
+ * @returns {import('express').Router}
21
+ */
22
+ export function createDoctorRouter() {
23
+ const router = Router();
24
+
25
+ // GET /api/doctor — full snapshot. Bounded by the underlying
26
+ // `collectDiagnostics()` (recentErrors is capped at 50, checks at
27
+ // ~12 entries, agent file list at directory size).
28
+ router.get('/', wrap(async (_req, res) => {
29
+ const snap = await collectDiagnostics();
30
+ info('doctor.snapshot', { status: snap.health.status, issues: snap.health.issues.length });
31
+ res.json(snap);
32
+ }));
33
+
34
+ // GET /api/doctor/health — rolled-up health. Cheap; the Doctor
35
+ // page polls this on every auto-refresh tick (30s) and only
36
+ // fetches the full snapshot when the user clicks "Refresh".
37
+ router.get('/health', wrap(async (_req, res) => {
38
+ const result = await health();
39
+ res.json(result);
40
+ }));
41
+
42
+ // POST /api/doctor/check — run a single named check.
43
+ //
44
+ // Body: `{ checkName: string }`.
45
+ //
46
+ // Returns the single check on success, or `{ status: 'fail',
47
+ // message: 'unknown check', error: '...' }` with HTTP 404 when
48
+ // the name doesn't match any registered check. The dashboard
49
+ // surfaces the message inline so the operator can fix the
50
+ // underlying issue without leaving the Doctor page.
51
+ router.post('/check', wrap(async (req, res) => {
52
+ const checkName = String(req.body?.checkName || '').trim();
53
+ if (!checkName) {
54
+ res.status(400).json({
55
+ status: 'fail',
56
+ name: '',
57
+ message: 'checkName required',
58
+ error: 'POST body must include { checkName: string }',
59
+ });
60
+ return;
61
+ }
62
+ const result = await runCheck(checkName);
63
+ if (result.status === 'fail' && result.message === 'unknown check') {
64
+ res.status(404).json(result);
65
+ return;
66
+ }
67
+ res.json(result);
68
+ }));
69
+
70
+ return router;
71
+ }
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * src/server/routes/env-vars.mjs
3
3
  *
4
- * v1 — Bizar env vars stored in ~/.config/bizar/env.json (mode 0600).
4
+ * v2 — Bizar env vars stored in ~/.config/bizar/env.json (mode 0600).
5
5
  *
6
6
  * Endpoints:
7
7
  * GET /api/env-vars — list all (masked values)
@@ -9,6 +9,9 @@
9
9
  * PUT /api/env-vars/:name — update {value}
10
10
  * DELETE /api/env-vars/:name — remove + delete from process.env
11
11
  * POST /api/env-vars/:name/test — {referenced, inProcessEnv}
12
+ * POST /api/env-vars/bulk-import — {ok, imported, skipped, errors[]}
13
+ * GET /api/env-vars/export — text/plain .env format
14
+ * GET /api/env-vars/grouped — { "BIZAR_*": [...], "PROVIDER_*": [...] }
12
15
  *
13
16
  * Name must match /^BIZAR_[A-Z0-9_]+$/.
14
17
  * On startup, loadEnvJson() sets process.env[name] = value for every entry.
@@ -161,5 +164,68 @@ export function createEnvVarsRouter() {
161
164
  res.json({ referenced: inProcessEnv, inProcessEnv });
162
165
  }));
163
166
 
167
+ // POST /api/env-vars/bulk-import — parse KEY=value lines
168
+ router.post('/env-vars/bulk-import', wrap(async (req, res) => {
169
+ const { envContent } = req.body || {};
170
+ if (typeof envContent !== 'string') {
171
+ res.status(400).json({ error: 'envContent_required', message: 'envContent string is required' });
172
+ return;
173
+ }
174
+ const lines = envContent.split('\n');
175
+ const store = readStore();
176
+ let imported = 0;
177
+ let skipped = 0;
178
+ const errors = [];
179
+
180
+ for (const line of lines) {
181
+ const trimmed = line.trim();
182
+ if (!trimmed || trimmed.startsWith('#')) { skipped++; continue; }
183
+ const eqIdx = trimmed.indexOf('=');
184
+ if (eqIdx < 1) { errors.push(`Invalid line (no '='): ${trimmed.slice(0, 40)}`); skipped++; continue; }
185
+ const rawName = trimmed.slice(0, eqIdx).trim();
186
+ const value = trimmed.slice(eqIdx + 1);
187
+ if (!NAME_RE.test(rawName)) { errors.push(`Invalid name: ${rawName}`); skipped++; continue; }
188
+ const name = rawName.toUpperCase();
189
+ if (store[name]) { errors.push(`Already exists (skipping): ${name}`); skipped++; continue; }
190
+ const entry = { value, createdAt: new Date().toISOString(), source: 'bulk-import' };
191
+ store[name] = entry;
192
+ process.env[name] = value;
193
+ imported++;
194
+ }
195
+
196
+ if (imported > 0) writeStore(store);
197
+ res.json({ ok: true, imported, skipped, errors });
198
+ }));
199
+
200
+ // GET /api/env-vars/export — return .env format
201
+ router.get('/env-vars/export', wrap(async (_req, res) => {
202
+ const store = readStore();
203
+ const lines = [];
204
+ for (const [name, entry] of Object.entries(store)) {
205
+ lines.push(`${name}=${entry.value}`);
206
+ }
207
+ res.setHeader('Content-Type', 'text/plain');
208
+ res.setHeader('Content-Disposition', 'attachment; filename=".env"');
209
+ res.send(lines.join('\n'));
210
+ }));
211
+
212
+ // GET /api/env-vars/grouped — return vars grouped by prefix
213
+ router.get('/env-vars/grouped', wrap(async (_req, res) => {
214
+ const store = readStore();
215
+ const groups = {};
216
+ for (const [name, entry] of Object.entries(store)) {
217
+ // Extract second-level prefix: BIZAR_EXT_A -> EXT_, BIZAR_PROVIDER_KEY -> PROVIDER_
218
+ const m = name.match(/^BIZAR_([A-Z0-9]+_)/);
219
+ const prefix = m ? m[1] : name;
220
+ (groups[prefix] ??= []).push({
221
+ name,
222
+ value: mask(entry.value),
223
+ createdAt: entry.createdAt,
224
+ source: entry.source,
225
+ });
226
+ }
227
+ res.json(groups);
228
+ }));
229
+
164
230
  return router;
165
231
  }
@@ -48,6 +48,9 @@ function redactLightRAGConfig(config) {
48
48
  }
49
49
 
50
50
  export function createMemoryRouter({ projectRoot }) {
51
+ // Auto-create the default vault and git-init if needed — idempotent.
52
+ memoryStore.ensureVaultExists();
53
+
51
54
  const router = Router();
52
55
 
53
56
  // GET /memory/status
@@ -31,24 +31,34 @@
31
31
  * `error` event and end the stream rather than risk file-descriptor
32
32
  * exhaustion when many tabs are open.
33
33
  *
34
- * The directory resolver walks `listOpencodeSessions` first (so a
35
- * session started from any worktree gets its own directory), then
36
- * falls back to `info.worktree` (the plugin's cwd). If neither is
37
- * known we 503.
34
+ * The directory resolver lives in `serve-info.mjs` as
35
+ * `resolveSessionDirectory(sessionId, serveInfo)` and is shared with
36
+ * `routes/opencode-sessions.mjs`. It probes the recorded worktree
37
+ * first (cheap), then falls back to listing every session and
38
+ * matching on id. If neither yields a directory we 503 with
39
+ * `directory_unknown`.
40
+ *
41
+ * v5.0.0 — bug #4 fix: structured error envelopes with `cause`,
42
+ * `status`, and `suggestion`; structured `logger.error` on every
43
+ * upstream failure for diagnostics.
38
44
  */
39
45
 
40
46
  import { Router } from 'express';
41
47
  import {
42
48
  readServeInfo,
43
49
  listOpencodeMessages,
44
- listOpencodeSessions,
50
+ resolveSessionDirectory,
45
51
  sendOpencodePrompt,
46
52
  unwrapOpencodeSseEvent,
47
53
  buildAuthHeader,
48
54
  extractContentFromOpencodeMessage,
49
55
  } from '../serve-info.mjs';
56
+ import { child as loggerChild } from '../logger.mjs';
50
57
  import { wrap } from './_shared.mjs';
51
58
 
59
+ /** Structured logger scoped to this route — emits `{module:'opencode-session-detail', ...}` on every line. */
60
+ const logger = loggerChild({ module: 'opencode-session-detail' });
61
+
52
62
  /** Maximum number of concurrent SSE subscribers on this dashboard process. */
53
63
  const MAX_SSE_SUBSCRIBERS = 50;
54
64
  /** Read the heartbeat interval on demand so tests can shrink it via
@@ -62,39 +72,6 @@ const LIST_MESSAGES_TIMEOUT_MS = 8_000;
62
72
  /** Default timeout for the upstream prompt endpoint. */
63
73
  const SEND_PROMPT_TIMEOUT_MS = 12_000;
64
74
 
65
- /**
66
- * Resolve the opencode `directory` query parameter for a session.
67
- *
68
- * Tries (in order):
69
- * 1. The session's own `location.directory` from `listOpencodeSessions`.
70
- * 2. `info.worktree` (the plugin's recorded cwd at startup).
71
- *
72
- * Returns `null` when neither is known — callers should 503 in that
73
- * case so the UI can show "directory unknown" rather than sending the
74
- * prompt with a blank directory (which the opencode API would 400).
75
- *
76
- * @param {ReturnType<typeof readServeInfo>} info
77
- * @param {string} sessionId
78
- * @returns {Promise<string|null>}
79
- */
80
- async function resolveSessionDirectory(info, sessionId) {
81
- if (!info) return null;
82
- try {
83
- const sessions = await listOpencodeSessions(info, 5_000);
84
- if (Array.isArray(sessions)) {
85
- const entry = sessions.find((s) => s && s.id === sessionId);
86
- const dir = entry?.location?.directory;
87
- if (typeof dir === 'string' && dir.length > 0) return dir;
88
- }
89
- } catch {
90
- /* fall through to worktree */
91
- }
92
- if (typeof info.worktree === 'string' && info.worktree.length > 0) {
93
- return info.worktree;
94
- }
95
- return null;
96
- }
97
-
98
75
  /**
99
76
  * Normalize an opencode message into the dashboard's chat shape:
100
77
  * { id, role, content, ts }
@@ -145,21 +122,44 @@ export function createOpencodeSessionDetailRouter() {
145
122
  return res.status(503).json({
146
123
  error: 'plugin_offline',
147
124
  message: 'opencode plugin is not running',
125
+ suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
148
126
  });
149
127
  }
150
- const directory = await resolveSessionDirectory(info, sessionId);
128
+ const directory = await resolveSessionDirectory(sessionId, info);
151
129
  if (!directory) {
152
130
  return res.status(503).json({
153
131
  error: 'directory_unknown',
154
132
  message:
155
133
  'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
134
+ suggestion: 'Create a new session or restart opencode serve.',
156
135
  });
157
136
  }
158
137
  const result = await listOpencodeMessages(info, sessionId, directory, LIST_MESSAGES_TIMEOUT_MS);
159
138
  if (!result?.ok) {
139
+ const errMsg = result?.error || 'unknown error from opencode serve';
140
+ // v5.0.0 — bug #4: prefer the structured `cause` field if the
141
+ // helper surfaced one (it carries `ECONNREFUSED` / `UND_ERR_*`
142
+ // from the underlying fetch); fall back to substring inference
143
+ // for older error message shapes.
144
+ let cause = result?.cause || 'unknown';
145
+ if (cause === 'unknown') {
146
+ if (errMsg.includes('timed out')) cause = 'timeout';
147
+ else if (errMsg.includes('network error') || errMsg.includes('fetch failed')) cause = 'network';
148
+ }
149
+ logger.error('opencode listMessages failed', {
150
+ sessionId,
151
+ worktree: directory,
152
+ servePort: info.port,
153
+ status: result?.status ?? null,
154
+ cause,
155
+ err: errMsg,
156
+ });
160
157
  return res.status(502).json({
161
158
  error: 'opencode_error',
162
- message: result?.error || 'unknown error from opencode serve',
159
+ message: errMsg,
160
+ cause,
161
+ status: result?.status ?? undefined,
162
+ suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
163
163
  });
164
164
  }
165
165
  const messages = Array.isArray(result.messages) ? result.messages.map(toChatMessage) : [];
@@ -191,14 +191,16 @@ export function createOpencodeSessionDetailRouter() {
191
191
  return res.status(503).json({
192
192
  error: 'plugin_offline',
193
193
  message: 'opencode plugin is not running',
194
+ suggestion: 'Run `bizar doctor` for diagnostics, or start opencode with `opencode serve`',
194
195
  });
195
196
  }
196
- const directory = await resolveSessionDirectory(info, sessionId);
197
+ const directory = await resolveSessionDirectory(sessionId, info);
197
198
  if (!directory) {
198
199
  return res.status(503).json({
199
200
  error: 'directory_unknown',
200
201
  message:
201
202
  'Cannot determine the opencode session directory; ensure the opencode plugin is running with serve.json containing worktree.',
203
+ suggestion: 'Create a new session or restart opencode serve.',
202
204
  });
203
205
  }
204
206
  // Synthesize a unique messageID so the client can correlate the
@@ -211,9 +213,26 @@ export function createOpencodeSessionDetailRouter() {
211
213
  SEND_PROMPT_TIMEOUT_MS,
212
214
  );
213
215
  if (!result?.ok) {
216
+ const errMsg = result?.error || 'unknown error from opencode serve';
217
+ let cause = result?.cause || 'unknown';
218
+ if (cause === 'unknown') {
219
+ if (errMsg.includes('timed out')) cause = 'timeout';
220
+ else if (errMsg.includes('network error') || errMsg.includes('fetch failed')) cause = 'network';
221
+ }
222
+ logger.error('opencode sendPrompt failed', {
223
+ sessionId,
224
+ worktree: directory,
225
+ servePort: info.port,
226
+ status: result?.status ?? null,
227
+ cause,
228
+ err: errMsg,
229
+ });
214
230
  return res.status(502).json({
215
231
  error: 'opencode_error',
216
- message: result?.error || 'unknown error from opencode serve',
232
+ message: errMsg,
233
+ cause,
234
+ status: result?.status ?? undefined,
235
+ suggestion: 'Check that the opencode serve child is running. Try `bizar doctor` or restart the plugin.',
217
236
  });
218
237
  }
219
238
  return res.json({ ok: true, messageId: result.messageId });
@@ -14,10 +14,31 @@
14
14
  * rest of the schedule.
15
15
  */
16
16
  import { Router } from 'express';
17
+ import { readFileSync, readdirSync, existsSync } from 'node:fs';
18
+ import { join, dirname } from 'node:path';
19
+ import { fileURLToPath } from 'node:url';
17
20
  import { schedulesStore } from '../schedules-store.mjs';
18
21
  import { schedulesRunner } from '../schedules-runner.mjs';
19
22
  import { readActiveProjectId, wrap } from './_shared.mjs';
20
23
 
24
+ const __dirname = dirname(fileURLToPath(import.meta.url));
25
+ const TEMPLATES_DIR = join(__dirname, '..', '..', '..', '..', 'templates', 'schedules');
26
+
27
+ /**
28
+ * Load all schedule templates from templates/schedules/.
29
+ * Each template gets `source: 'template'` and `templateFile` added.
30
+ * Missing directory returns an empty array.
31
+ */
32
+ function loadTemplates() {
33
+ if (!existsSync(TEMPLATES_DIR)) return [];
34
+ return readdirSync(TEMPLATES_DIR)
35
+ .filter((f) => f.endsWith('.json'))
36
+ .map((f) => {
37
+ const content = JSON.parse(readFileSync(join(TEMPLATES_DIR, f), 'utf8'));
38
+ return { ...content, source: 'template', templateFile: f };
39
+ });
40
+ }
41
+
21
42
  /**
22
43
  * @param {object} deps
23
44
  * @param {Function} deps.broadcast
@@ -100,5 +121,39 @@ export function createSchedulesRouter({ broadcast }) {
100
121
  res.status(204).end();
101
122
  }));
102
123
 
124
+ // GET /api/schedules/templates → { templates: [...] }
125
+ router.get('/schedules/templates', wrap(async (_req, res) => {
126
+ res.json({ templates: loadTemplates() });
127
+ }));
128
+
129
+ // POST /api/schedules/from-template → creates a schedule from a template
130
+ router.post('/schedules/from-template', wrap(async (req, res) => {
131
+ const projectId = req.body?.projectId || readActiveProjectId() || 'default';
132
+ const { templateId, name: customName } = req.body || {};
133
+ if (!templateId) {
134
+ res.status(400).json({ error: 'templateId is required' });
135
+ return;
136
+ }
137
+ const templates = loadTemplates();
138
+ const template = templates.find((t) => t.id === templateId);
139
+ if (!template) {
140
+ res.status(404).json({ error: 'template not found' });
141
+ return;
142
+ }
143
+ // Build a schedule payload from the template, allowing name override
144
+ const schedulePayload = {
145
+ name: customName || template.name,
146
+ type: template.type,
147
+ schedule: template.schedule,
148
+ timezone: template.timezone || 'UTC',
149
+ action: template.action,
150
+ budgetCheck: template.budgetCheck,
151
+ enabled: true,
152
+ };
153
+ const sched = schedulesStore.add(projectId, schedulePayload);
154
+ broadcast({ type: 'schedules:change' });
155
+ res.status(201).json(sched);
156
+ }));
157
+
103
158
  return router;
104
159
  }
@@ -56,6 +56,7 @@ import { readFileSync, existsSync } from 'node:fs';
56
56
  import { join } from 'node:path';
57
57
  import { homedir } from 'node:os';
58
58
  import { createConnection, isIP } from 'node:net';
59
+ import { warn as loggerWarn, error as loggerError } from './logger.mjs';
59
60
 
60
61
  const HOME = homedir();
61
62
 
@@ -321,6 +322,147 @@ export async function listOpencodeSessions(info, timeoutMs = 5000) {
321
322
 
322
323
  export const SERVE_INFO_FILE_PATHS = DEFAULT_SERVE_INFO_FILES;
323
324
 
325
+ // ── v5.0.0 — bug #4 — directory resolver (shared between route modules) ──
326
+ //
327
+ // Both `routes/opencode-sessions.mjs` and `routes/opencode-session-detail.mjs`
328
+ // need the same logic: given a sessionId and the plugin's serve-info, figure
329
+ // out which `directory` (worktree) to pass as a `?directory=…` query param
330
+ // to opencode's HTTP API. We previously had two copies of this resolver
331
+ // inline in each route; now there's one in serve-info.mjs so a fix here
332
+ // reaches both routes.
333
+ //
334
+ // Strategy (per issue #4 brief):
335
+ // 1. If serve.json has a `worktree` AND a fast probe (`GET
336
+ // /api/session/{id}?directory=<worktree>`) succeeds (HTTP 200), use
337
+ // the worktree — no need to list every opencode session.
338
+ // 2. Otherwise, list every session via `GET /api/session` and find the
339
+ // one whose id matches. Use its `location.directory` (or the legacy
340
+ // `worktree` field, if present) as the directory.
341
+ // 3. If neither yields a directory, return null — callers should 503
342
+ // so the UI can show "directory unknown" rather than sending the
343
+ // request with a blank `?directory=` (opencode 400s on that).
344
+ //
345
+ // `worktreeHasSession` is a small `GET /api/session/{id}?directory=...`
346
+ // probe used as a fast-path so we don't pay the cost of listing every
347
+ // session on every listMessages call. The endpoint returns 200 when the
348
+ // session exists in that worktree, 404 when it doesn't. We treat 2xx
349
+ // other than 200 as success (opencode has shipped both `200` and `204`
350
+ // on this endpoint across versions).
351
+
352
+ /**
353
+ * Lightweight probe — does `sessionId` exist in `worktree`?
354
+ *
355
+ * Implemented as `GET /api/session/{id}?directory={worktree}`. Returns
356
+ * `true` on 2xx, `false` on 404, `null` on network / auth / other
357
+ * errors (the caller should fall back to the slower list path and
358
+ * trust THAT result).
359
+ *
360
+ * Never throws.
361
+ *
362
+ * @param {ServeInfo} info
363
+ * @param {string} sessionId
364
+ * @param {string} worktree
365
+ * @param {number} [timeoutMs]
366
+ * @returns {Promise<boolean|null>}
367
+ */
368
+ async function worktreeHasSession(info, sessionId, worktree, timeoutMs = 2_500) {
369
+ if (!info || !sessionId || !worktree) return null;
370
+ const url = `${info.baseUrl}/api/session/${encodeURIComponent(sessionId)}?directory=${encodeURIComponent(worktree)}`;
371
+ const ac = new AbortController();
372
+ const timer = setTimeout(() => ac.abort(), timeoutMs);
373
+ try {
374
+ const res = await fetch(url, {
375
+ method: 'GET',
376
+ headers: {
377
+ Authorization: buildAuthHeader(info),
378
+ Accept: 'application/json',
379
+ },
380
+ signal: ac.signal,
381
+ });
382
+ if (res.status >= 200 && res.status < 300) return true;
383
+ if (res.status === 404) return false;
384
+ // 401/403/5xx → don't trust the result, let the fallback path decide.
385
+ return null;
386
+ } catch {
387
+ return null;
388
+ } finally {
389
+ clearTimeout(timer);
390
+ }
391
+ }
392
+
393
+ /**
394
+ * Resolve the opencode `directory` (worktree) for a session.
395
+ *
396
+ * Per the brief:
397
+ * 1. If we have a worktree AND a fast probe confirms the session
398
+ * lives there, return it.
399
+ * 2. Otherwise, list every session and find the matching one; use
400
+ * its `location.directory` (or legacy `worktree`).
401
+ * 3. Return null if neither yields a directory.
402
+ *
403
+ * Signature is `(sessionId, serveInfo)` (positional, sessionId first)
404
+ * to match the brief. Callers that only have `serveInfo` can pass
405
+ * `null` for `sessionId` and the function will fall through to the
406
+ * list path.
407
+ *
408
+ * Never throws.
409
+ *
410
+ * @param {string|null|undefined} sessionId
411
+ * @param {ServeInfo|null|undefined} serveInfo
412
+ * @returns {Promise<string|null>}
413
+ */
414
+ export async function resolveSessionDirectory(sessionId, serveInfo) {
415
+ if (!serveInfo) return null;
416
+
417
+ // Fast path: probe the recorded worktree first. If the session lives
418
+ // there, we save the round-trip to /api/session.
419
+ if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
420
+ const probe = await worktreeHasSession(serveInfo, sessionId || '', serveInfo.worktree);
421
+ if (probe === true) return serveInfo.worktree;
422
+ }
423
+
424
+ // Slower fallback: list every session and find the match.
425
+ try {
426
+ const sessions = await listOpencodeSessions(serveInfo, 5_000);
427
+ if (Array.isArray(sessions)) {
428
+ const entry = sessionId
429
+ ? sessions.find(
430
+ (s) =>
431
+ s && (s.id === sessionId || (typeof s.sessionId === 'string' && s.sessionId === sessionId)),
432
+ )
433
+ : null;
434
+ if (entry) {
435
+ const dir = entry?.location?.directory;
436
+ if (typeof dir === 'string' && dir.length > 0) return dir;
437
+ // Defensive: some opencode builds store the worktree on the
438
+ // session at the top level instead of under `location`.
439
+ const legacy = entry?.worktree;
440
+ if (typeof legacy === 'string' && legacy.length > 0) return legacy;
441
+ }
442
+ // No matching session, but we have a worktree from serve.json —
443
+ // last-chance fallback. This mirrors the pre-v5.0.0 inline
444
+ // resolver behaviour so an unmatched session still routes to
445
+ // the plugin's recorded cwd rather than 503'ing.
446
+ if (!entry && typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
447
+ return serveInfo.worktree;
448
+ }
449
+ } else if (typeof serveInfo.worktree === 'string' && serveInfo.worktree.length > 0) {
450
+ // listOpencodeSessions returned null (serve offline / auth fail).
451
+ // Fall back to the worktree so we at least try the same
452
+ // directory the plugin recorded — the upstream 404/502 will
453
+ // surface a clearer error than a 503 directory_unknown.
454
+ return serveInfo.worktree;
455
+ }
456
+ } catch (err) {
457
+ loggerWarn('resolveSessionDirectory: listOpencodeSessions failed', {
458
+ sessionId: sessionId || null,
459
+ err: err instanceof Error ? err.message : String(err),
460
+ });
461
+ }
462
+
463
+ return null;
464
+ }
465
+
324
466
  // ── v3.5.4 (bug: dispatch stuck) — spawn helpers ─────────────────────────
325
467
  //
326
468
  // The dashboard's task delegator used to shell out to
@@ -473,8 +615,21 @@ export async function sendOpencodePrompt(info, opts, directory, timeoutMs = DEFA
473
615
  } catch (err) {
474
616
  const msg = err instanceof Error ? err.message : String(err);
475
617
  const isAbort = err instanceof Error && err.name === 'AbortError';
618
+ let cause = 'unknown';
619
+ if (isAbort) cause = 'timeout';
620
+ else if (err && typeof err === 'object') {
621
+ const code = /** @type {any} */ (err).code;
622
+ if (typeof code === 'string' && code.length > 0) {
623
+ cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
624
+ ? 'network'
625
+ : code;
626
+ } else {
627
+ cause = 'network';
628
+ }
629
+ }
476
630
  return {
477
631
  ok: false,
632
+ cause,
478
633
  error: isAbort ? `sendPrompt timed out after ${timeoutMs}ms` : `sendPrompt network error: ${msg}`,
479
634
  };
480
635
  } finally {
@@ -543,8 +698,25 @@ export async function listOpencodeMessages(info, sessionId, directory, timeoutMs
543
698
  } catch (err) {
544
699
  const msg = err instanceof Error ? err.message : String(err);
545
700
  const isAbort = err instanceof Error && err.name === 'AbortError';
701
+ // v5.0.0 — bug #4: surface a structured `cause` to the route so it
702
+ // can render a useful suggestion without substring-sniffing the
703
+ // error message. Prefers the underlying network error code
704
+ // (ECONNREFUSED, UND_ERR_SOCKET, …) when present.
705
+ let cause = 'unknown';
706
+ if (isAbort) cause = 'timeout';
707
+ else if (err && typeof err === 'object') {
708
+ const code = /** @type {any} */ (err).code;
709
+ if (typeof code === 'string' && code.length > 0) {
710
+ cause = code.startsWith('ECONN') || code.startsWith('UND_ERR') || code === 'ENOTFOUND'
711
+ ? 'network'
712
+ : code;
713
+ } else {
714
+ cause = 'network';
715
+ }
716
+ }
546
717
  return {
547
718
  ok: false,
719
+ cause,
548
720
  error: isAbort ? `listMessages timed out after ${timeoutMs}ms` : `listMessages network error: ${msg}`,
549
721
  };
550
722
  } finally {