@polderlabs/bizar 5.4.0 → 5.5.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 (99) hide show
  1. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
  2. package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
  3. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
  4. package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
  5. package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
  6. package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
  7. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
  8. package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
  9. package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
  10. package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
  11. package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
  12. package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
  13. package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
  14. package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
  15. package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
  16. package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
  17. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
  18. package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
  19. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
  20. package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
  21. package/bizar-dash/dist/index.html +8 -8
  22. package/bizar-dash/dist/mobile.html +4 -4
  23. package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
  24. package/bizar-dash/src/server/backup-store.mjs +6 -1
  25. package/bizar-dash/src/server/bg-spawner.mjs +574 -0
  26. package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
  27. package/bizar-dash/src/server/memory-store.mjs +43 -3
  28. package/bizar-dash/src/server/routes/background.mjs +143 -0
  29. package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
  30. package/bizar-dash/src/server/routes/memory.mjs +8 -0
  31. package/bizar-dash/src/server/server.mjs +22 -0
  32. package/bizar-dash/src/web/App.tsx +3 -0
  33. package/bizar-dash/src/web/MobileApp.tsx +1 -10
  34. package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
  35. package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
  36. package/bizar-dash/src/web/lib/types.ts +39 -1
  37. package/bizar-dash/src/web/styles/main.css +43 -0
  38. package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
  39. package/bizar-dash/src/web/styles/settings.css +46 -0
  40. package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
  41. package/bizar-dash/src/web/views/Settings.tsx +12 -12
  42. package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
  43. package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
  44. package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
  45. package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
  46. package/bizar-dash/tests/background-steer.test.mjs +42 -0
  47. package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
  48. package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
  49. package/bizar-dash/tests/memory-cli.test.mjs +1 -1
  50. package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
  51. package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
  52. package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
  53. package/bizar-dash/tests/settings-layout.test.tsx +3 -3
  54. package/bizar-dash/tests/settings-nav.test.tsx +7 -5
  55. package/cli/bin.mjs +16 -0
  56. package/cli/commands/deploy/docker.mjs +2 -1
  57. package/cli/commands/lightrag.mjs +171 -0
  58. package/cli/commands/service.mjs +7 -0
  59. package/cli/commands/util.mjs +8 -1
  60. package/cli/init.mjs +1 -1
  61. package/cli/post-install-smoke.mjs +230 -0
  62. package/cli/provision.mjs +177 -0
  63. package/cli/provision.test.mjs +180 -0
  64. package/cli/service-controller.mjs +378 -28
  65. package/cli/service-controller.test.mjs +100 -0
  66. package/cli/service-env.mjs +139 -0
  67. package/cli/service.mjs +23 -0
  68. package/config/agents/_shared/AGENT_BASELINE.md +6 -6
  69. package/install.sh +87 -6
  70. package/package.json +1 -1
  71. package/plugins/bizar/index.ts +147 -0
  72. package/plugins/bizar/src/background-state.ts +84 -5
  73. package/plugins/bizar/src/background.ts +388 -10
  74. package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
  75. package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
  76. package/plugins/bizar/src/opencode-runner.ts +87 -1
  77. package/plugins/bizar/src/tools/bg-pause.ts +77 -0
  78. package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
  79. package/plugins/bizar/src/tools/bg-resume.ts +72 -0
  80. package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
  81. package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
  82. package/plugins/bizar/src/tools/bg-status.ts +10 -0
  83. package/plugins/bizar/src/tools/memory-list.ts +135 -0
  84. package/plugins/bizar/src/tools/memory-read.ts +142 -0
  85. package/plugins/bizar/src/tools/memory-search.ts +228 -0
  86. package/plugins/bizar/src/tools/memory-write.ts +183 -0
  87. package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
  88. package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
  89. package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
  90. package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
  91. package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
  92. package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
  93. package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
  94. package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
  95. package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
  96. package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
  97. package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
  98. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
  99. package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
@@ -0,0 +1,247 @@
1
+ /**
2
+ * plugins/bizar/src/hooks/memory-inject.ts
3
+ *
4
+ * Session-start memory injection hook.
5
+ *
6
+ * Fires on `session.created` event, searches the memory vault for
7
+ * relevant context based on the user's initial message, and queues a
8
+ * Memory Context injection into the system prompt via
9
+ * `experimental.chat.system.transform`.
10
+ *
11
+ * Key design decisions:
12
+ * - Fires ONCE per session (idempotent via `ctx._memoryInjectedSessions`)
13
+ * - Uses the first user message text as the search query
14
+ * - Reads top 3 results and injects a "## Memory Context" section
15
+ * - Skips if no results found (no empty section)
16
+ * - Configurable via `settings.memory.injectOnSessionStart` (default true)
17
+ * - Non-blocking: memory search is fire-and-forget
18
+ */
19
+
20
+ import { existsSync, readFileSync } from "node:fs";
21
+ import { join } from "node:path";
22
+ import { homedir } from "node:os";
23
+
24
+ import type { Logger } from "../logger.js";
25
+
26
+ /** How many memory results to inject at most */
27
+ const MAX_INJECT_RESULTS = 3;
28
+
29
+ /**
30
+ * Resolve the dashboard server port. Returns null if not available.
31
+ */
32
+ export async function resolveDashboardPort(): Promise<number | null> {
33
+ const portFile = join(homedir(), ".config", "bizar", "dashboard.port");
34
+ if (existsSync(portFile)) {
35
+ try {
36
+ const raw = readFileSync(portFile, "utf8").trim();
37
+ const n = parseInt(raw, 10);
38
+ if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
39
+ } catch {
40
+ // fall through
41
+ }
42
+ }
43
+ const envPort = process.env.BIZAR_DASHBOARD_PORT;
44
+ if (envPort) {
45
+ const n = parseInt(envPort, 10);
46
+ if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
47
+ }
48
+ return null;
49
+ }
50
+
51
+ /**
52
+ * Search memory vault via the dashboard API.
53
+ * Returns up to `limit` results.
54
+ */
55
+ async function searchMemory(
56
+ port: number,
57
+ query: string,
58
+ limit = 5,
59
+ ): Promise<Array<{ path: string | null; snippet: string; score: number }>> {
60
+ try {
61
+ // Try semantic first, then fall back to FTS
62
+ const baseUrl = `http://127.0.0.1:${port}`;
63
+
64
+ // Semantic search via LightRAG
65
+ const semRes = await fetch(`${baseUrl}/api/memory/semantic-search`, {
66
+ method: "POST",
67
+ headers: { "Content-Type": "application/json" },
68
+ body: JSON.stringify({ query, limit, sources: ["lightrag", "obsidian"] }),
69
+ });
70
+
71
+ if (semRes.ok) {
72
+ const data = (await semRes.json()) as {
73
+ results?: Array<{
74
+ relPath?: string | null;
75
+ snippet?: string;
76
+ score?: number;
77
+ }>;
78
+ };
79
+ if (data.results && data.results.length > 0) {
80
+ return (data.results).map((r) => ({
81
+ path: r.relPath ?? null,
82
+ snippet: typeof r.snippet === "string" ? r.snippet.slice(0, 500) : String(r.snippet ?? ""),
83
+ score: typeof r.score === "number" ? r.score : 1,
84
+ }));
85
+ }
86
+ }
87
+
88
+ // Fall back to FTS
89
+ const ftsRes = await fetch(
90
+ `${baseUrl}/api/memory/search?q=${encodeURIComponent(query)}&limit=${limit}`,
91
+ { method: "GET" },
92
+ );
93
+ if (ftsRes.ok) {
94
+ const data = (await ftsRes.json()) as {
95
+ results?: Array<{ relPath?: string; snippet?: string; score?: number }>;
96
+ };
97
+ if (data.results) {
98
+ return data.results.map((r) => ({
99
+ path: r.relPath ?? null,
100
+ snippet: typeof r.snippet === "string" ? r.snippet.slice(0, 500) : String(r.snippet ?? ""),
101
+ score: typeof r.score === "number" ? r.score : 0,
102
+ }));
103
+ }
104
+ }
105
+ } catch {
106
+ // non-fatal: memory injection should not break session start
107
+ }
108
+ return [];
109
+ }
110
+
111
+ /**
112
+ * Extract a search query from the first user message.
113
+ * Uses a simple heuristic: first 50 chars + key nouns.
114
+ */
115
+ function buildSearchQuery(messageText: string): string {
116
+ // Strip /commands, whitespace, quotes
117
+ const cleaned = messageText.replace(/^[\/\s"\']+/, "").trim();
118
+ // Take first meaningful chunk (up to 80 chars)
119
+ const core = cleaned.slice(0, 80).split(/[.\n!?]/)[0] ?? cleaned;
120
+ return core || cleaned.slice(0, 50);
121
+ }
122
+
123
+ /**
124
+ * Format a memory result as a markdown section for injection.
125
+ */
126
+ function formatMemoryContext(
127
+ results: Array<{ path: string | null; snippet: string; score: number }>,
128
+ ): string {
129
+ const lines = ["## Memory Context\n"];
130
+ lines.push(
131
+ `_Automatically injected from the Bizar Memory vault. ${results.length} relevant note(s) found._\n`,
132
+ );
133
+ for (const r of results.slice(0, MAX_INJECT_RESULTS)) {
134
+ if (r.path) {
135
+ lines.push(`### ${r.path}`);
136
+ } else {
137
+ lines.push("### (semantic match)");
138
+ }
139
+ lines.push(`_${r.score > 0 ? `score: ${r.score.toFixed(2)}` : "full-text match"}_\n`);
140
+ lines.push(r.snippet);
141
+ lines.push("\n---\n");
142
+ }
143
+ return lines.join("\n");
144
+ }
145
+
146
+ export interface MemoryInjectDeps {
147
+ /** Set of sessionIds that have already been injected (cleared on session.deleted). */
148
+ injectedSessions: Set<string>;
149
+ worktree: string;
150
+ logger: Logger;
151
+ /** If false, skip injection entirely (configurable via settings). */
152
+ enabled: boolean;
153
+ }
154
+
155
+ /**
156
+ * Build the session-start memory injection logic.
157
+ * Call this from the `event` hook for `session.created` events.
158
+ *
159
+ * Usage in index.ts `event` hook:
160
+ * const memoryInjectDeps = { injectedSessions, worktree, logger, enabled: true };
161
+ * const { memoryInject } = createMemoryInject(memoryInjectDeps);
162
+ * // in event handler:
163
+ * if (type === "session.created") {
164
+ * memoryInject(sessionID, userMessageText).catch(() => {});
165
+ * }
166
+ *
167
+ * The `injectedSessions` Set is also checked/cleared in `session.deleted`.
168
+ */
169
+ export function createMemoryInject(deps: MemoryInjectDeps) {
170
+ const { injectedSessions, logger } = deps;
171
+
172
+ /**
173
+ * Fire memory search and queue the result for injection.
174
+ * Safe to await or fire-and-forget — errors are swallowed.
175
+ */
176
+ async function memoryInject(
177
+ sessionID: string,
178
+ userMessageText: string,
179
+ signal?: AbortSignal,
180
+ ): Promise<void> {
181
+ if (!deps.enabled) return;
182
+ if (injectedSessions.has(sessionID)) return;
183
+
184
+ // Mark injected BEFORE the async work so we don't double-fire
185
+ // even if the request races.
186
+ injectedSessions.add(sessionID);
187
+
188
+ try {
189
+ const port = await resolveDashboardPort();
190
+ if (!port) return;
191
+
192
+ const query = buildSearchQuery(userMessageText);
193
+ if (!query) return;
194
+
195
+ const results = await searchMemory(port, query, 5);
196
+ if (results.length === 0) return;
197
+
198
+ const context = formatMemoryContext(results);
199
+
200
+ // We can't directly push to output.system here (we're in the `event` hook).
201
+ // Instead, we use the `pendingInjections` map in the RuntimeContext
202
+ // (same mechanism used for loop guard injections).
203
+ // The `experimental.chat.system.transform` hook already checks this map.
204
+ // We store it under a special marker so the transform hook can distinguish it.
205
+ // But wait — pendingInjections only holds ONE message at a time.
206
+ // We need a separate mechanism... Actually, looking at the transform hook:
207
+ // const pending = ctx.pendingInjections.get(sessionID);
208
+ // if (pending) { output.system.push(pending); ctx.pendingInjections.delete(sessionID); }
209
+ // This only allows ONE injection at a time.
210
+ //
211
+ // Alternative: use a separate Map for memory injections.
212
+ // The transform hook checks ctx._memoryInjections.get(sessionID) separately.
213
+ //
214
+ // Since we control both the injection (here) and the hook (in index.ts),
215
+ // we can add a second map. Let's use ctx._memoryPendingInjections.
216
+
217
+ // Signal that we have memory to inject by setting a flag.
218
+ // The actual injection happens in experimental.chat.system.transform.
219
+ // We use a module-level store that the transform hook reads.
220
+ _pendingMemoryContext.set(sessionID, context);
221
+ } catch (err) {
222
+ // Non-fatal: don't let a failed memory injection break session start.
223
+ logger.debug(
224
+ `bizar: memory-inject failed for session ${sessionID}: ${err instanceof Error ? err.message : String(err)}`,
225
+ );
226
+ }
227
+ }
228
+
229
+ return { memoryInject };
230
+ }
231
+
232
+ /**
233
+ * Module-level store for pending memory context injections.
234
+ * The `experimental.chat.system.transform` hook reads this and injects
235
+ * the queued context into the system prompt.
236
+ */
237
+ export const _pendingMemoryContext = new Map<string, string>();
238
+
239
+ /**
240
+ * Retrieve and clear any pending memory context for a session.
241
+ * Called from `experimental.chat.system.transform`.
242
+ */
243
+ export function popMemoryContext(sessionID: string): string | null {
244
+ const ctx = _pendingMemoryContext.get(sessionID);
245
+ _pendingMemoryContext.delete(sessionID);
246
+ return ctx ?? null;
247
+ }
@@ -0,0 +1,188 @@
1
+ /**
2
+ * plugins/bizar/src/hooks/memory-write-on-end.ts
3
+ *
4
+ * Session-end memory write hook.
5
+ *
6
+ * Fires on `session.idle` (natural completion) or `session.error` events.
7
+ * Writes an automated session summary to the memory vault at:
8
+ * sessions/<date>-<session-id>.md
9
+ *
10
+ * Key design decisions:
11
+ * - Configurable: `settings.memory.writeOnSessionEnd` (default true)
12
+ * - Non-blocking: write is fire-and-forget (success logged, failure warned)
13
+ * - Idempotent: if the same session fires multiple terminal events,
14
+ * only the first write succeeds (check if note already exists via API)
15
+ * - Uses the dashboard API (POST /api/memory/notes) for consistency
16
+ */
17
+
18
+ import { existsSync, readFileSync } from "node:fs";
19
+ import { join } from "node:path";
20
+ import { homedir } from "node:os";
21
+
22
+ import type { Logger } from "../logger.js";
23
+
24
+ const SESSION_NOTES_PREFIX = "sessions/";
25
+
26
+ export interface MemoryWriteOnEndDeps {
27
+ worktree: string;
28
+ logger: Logger;
29
+ enabled: boolean;
30
+ }
31
+
32
+ /**
33
+ * Resolve the dashboard server port. Returns null if not available.
34
+ */
35
+ export async function resolveDashboardPort(): Promise<number | null> {
36
+ const portFile = join(homedir(), ".config", "bizar", "dashboard.port");
37
+ if (existsSync(portFile)) {
38
+ try {
39
+ const raw = readFileSync(portFile, "utf8").trim();
40
+ const n = parseInt(raw, 10);
41
+ if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
42
+ } catch {
43
+ // fall through
44
+ }
45
+ }
46
+ const envPort = process.env.BIZAR_DASHBOARD_PORT;
47
+ if (envPort) {
48
+ const n = parseInt(envPort, 10);
49
+ if (Number.isFinite(n) && n > 0 && n <= 65535) return n;
50
+ }
51
+ return null;
52
+ }
53
+
54
+ interface SessionEndInfo {
55
+ sessionID: string;
56
+ agent: string;
57
+ startedAt: number;
58
+ endedAt: number;
59
+ status: "idle" | "error" | "killed";
60
+ error?: string;
61
+ }
62
+
63
+ /**
64
+ * Build a session summary note body.
65
+ */
66
+ function buildSessionSummary(
67
+ info: SessionEndInfo,
68
+ conversationPreview: string,
69
+ ): string {
70
+ const durationMin = Math.round((info.endedAt - info.startedAt) / 60_000);
71
+ const started = new Date(info.startedAt).toISOString();
72
+ const ended = new Date(info.endedAt).toISOString();
73
+
74
+ const lines: string[] = [
75
+ `# Session Summary: ${info.sessionID.slice(0, 8)}…`,
76
+ "",
77
+ `**Agent:** ${info.agent}`,
78
+ `**Started:** ${started}`,
79
+ `**Ended:** ${ended}`,
80
+ `**Duration:** ~${durationMin} min`,
81
+ `**Status:** ${info.status}${info.error ? ` — ${info.error}` : ""}`,
82
+ "",
83
+ "## Conversation Preview",
84
+ "",
85
+ conversationPreview.slice(0, 2000),
86
+ "",
87
+ "## Key Decisions",
88
+ "",
89
+ "- *(add your key decisions here)*",
90
+ "",
91
+ "## Tools Used",
92
+ "",
93
+ "- *(tools used during this session)*",
94
+ "",
95
+ "## Errors / Issues",
96
+ info.error ? `\n${info.error}\n` : "- *(no errors)*",
97
+ ];
98
+
99
+ return lines.join("\n");
100
+ }
101
+
102
+ /**
103
+ * Write a session summary note to the memory vault.
104
+ */
105
+ async function writeSessionSummary(
106
+ port: number,
107
+ info: SessionEndInfo,
108
+ conversationPreview: string,
109
+ worktree: string,
110
+ logger: Logger,
111
+ ): Promise<void> {
112
+ const date = new Date(info.startedAt).toISOString().slice(0, 10); // YYYY-MM-DD
113
+ const slug = `${date}-${info.sessionID.slice(0, 8)}.md`;
114
+ const relPath = `${SESSION_NOTES_PREFIX}${slug}`;
115
+
116
+ const body = buildSessionSummary(info, conversationPreview);
117
+
118
+ const payload = {
119
+ path: relPath,
120
+ frontmatter: {
121
+ type: "session-summary",
122
+ session_id: info.sessionID,
123
+ started_at: new Date(info.startedAt).toISOString(),
124
+ ended_at: new Date(info.endedAt).toISOString(),
125
+ duration_minutes: Math.round((info.endedAt - info.startedAt) / 60_000),
126
+ agent: info.agent,
127
+ project: worktree.split("/").pop() ?? "unknown",
128
+ tags: ["auto-generated"],
129
+ status: info.status,
130
+ },
131
+ body,
132
+ };
133
+
134
+ try {
135
+ const res = await fetch(`http://127.0.0.1:${port}/api/memory/notes`, {
136
+ method: "POST",
137
+ headers: { "Content-Type": "application/json" },
138
+ body: JSON.stringify(payload),
139
+ });
140
+
141
+ if (res.ok || res.status === 409) {
142
+ // 409 = already exists (idempotent)
143
+ logger.debug(`bizar: session summary written for ${info.sessionID.slice(0, 8)}`);
144
+ } else {
145
+ const text = await res.text().catch(() => "");
146
+ logger.warn(`bizar: session summary write failed for ${info.sessionID.slice(0, 8)}: HTTP ${res.status} — ${text.slice(0, 200)}`);
147
+ }
148
+ } catch (err) {
149
+ const msg = err instanceof Error ? err.message : String(err);
150
+ logger.warn(`bizar: session summary write failed for ${info.sessionID.slice(0, 8)}: ${msg}`);
151
+ }
152
+ }
153
+
154
+ /**
155
+ * Create the session-end memory write hook.
156
+ *
157
+ * Usage in index.ts `event` hook:
158
+ * const { memoryWriteOnEnd } = createMemoryWriteOnEnd(deps);
159
+ * if (type === "session.idle" || type === "session.error") {
160
+ * memoryWriteOnEnd(sessionID, eventPayload).catch(() => {});
161
+ * }
162
+ */
163
+ export function createMemoryWriteOnEnd(deps: MemoryWriteOnEndDeps) {
164
+ const { logger } = deps;
165
+
166
+ /** Sessions that have already been written (idempotency). */
167
+ const _writtenSessions = new Set<string>();
168
+
169
+ async function memoryWriteOnEnd(
170
+ sessionID: string,
171
+ info: SessionEndInfo,
172
+ conversationPreview: string,
173
+ ): Promise<void> {
174
+ if (!deps.enabled) return;
175
+ if (_writtenSessions.has(sessionID)) return;
176
+ _writtenSessions.add(sessionID);
177
+
178
+ const port = await resolveDashboardPort();
179
+ if (!port) {
180
+ logger.debug(`bizar: session-end memory write skipped — dashboard not running`);
181
+ return;
182
+ }
183
+
184
+ await writeSessionSummary(port, info, conversationPreview, deps.worktree, logger);
185
+ }
186
+
187
+ return { memoryWriteOnEnd };
188
+ }
@@ -51,7 +51,13 @@ import { createWriteStream, mkdirSync, existsSync } from "node:fs";
51
51
  import { dirname } from "node:path";
52
52
  import type { Subprocess } from "bun";
53
53
 
54
- export type AgentState = "starting" | "running" | "done" | "failed" | "killed";
54
+ export type AgentState =
55
+ | "starting"
56
+ | "running"
57
+ | "paused"
58
+ | "done"
59
+ | "failed"
60
+ | "killed";
55
61
 
56
62
  export interface AgentStatus {
57
63
  state: AgentState;
@@ -373,6 +379,86 @@ export function killAgent(
373
379
  return { ok: true };
374
380
  }
375
381
 
382
+ /**
383
+ * v5.x — Pause a tracked agent by sending SIGSTOP. POSIX only —
384
+ * Windows has no signal model and returns `ok: false`. Idempotent
385
+ * against an exited process (returns `ok: true` with a note).
386
+ *
387
+ * The agent's `state` flips to `"paused"` so {@link getStatus}
388
+ * reflects the new lifecycle accurately.
389
+ */
390
+ export function pauseAgent(
391
+ processId: number,
392
+ ): { ok: boolean; error?: string } {
393
+ if (process.platform === "win32") {
394
+ return { ok: false, error: "pause unsupported on win32" };
395
+ }
396
+ const rec = agents.get(processId);
397
+ if (!rec) return { ok: false, error: "no such process" };
398
+ if (rec.status.endedAt !== undefined) return { ok: true, error: "already_exited" };
399
+ if (rec.status.state === "paused") return { ok: true };
400
+ try {
401
+ rec.proc.kill("SIGSTOP");
402
+ rec.status.state = "paused";
403
+ return { ok: true };
404
+ } catch (err: unknown) {
405
+ return {
406
+ ok: false,
407
+ error: `SIGSTOP failed: ${err instanceof Error ? err.message : String(err)}`,
408
+ };
409
+ }
410
+ }
411
+
412
+ /**
413
+ * v5.x — Resume a paused agent by sending SIGCONT. POSIX only. No-op
414
+ * against an exited process.
415
+ */
416
+ export function resumeAgent(
417
+ processId: number,
418
+ ): { ok: boolean; error?: string } {
419
+ if (process.platform === "win32") {
420
+ return { ok: false, error: "resume unsupported on win32" };
421
+ }
422
+ const rec = agents.get(processId);
423
+ if (!rec) return { ok: false, error: "no such process" };
424
+ if (rec.status.endedAt !== undefined) return { ok: true, error: "already_exited" };
425
+ if (rec.status.state !== "paused") return { ok: true };
426
+ try {
427
+ rec.proc.kill("SIGCONT");
428
+ rec.status.state = "running";
429
+ return { ok: true };
430
+ } catch (err: unknown) {
431
+ return {
432
+ ok: false,
433
+ error: `SIGCONT failed: ${err instanceof Error ? err.message : String(err)}`,
434
+ };
435
+ }
436
+ }
437
+
438
+ /**
439
+ * v5.x — Liveness check for a previously spawned PID. Uses
440
+ * `process.kill(pid, 0)` which sends no signal — it just probes the
441
+ * process table. Returns `false` for unknown / exited PIDs and on
442
+ * permission errors.
443
+ *
444
+ * NOTE: this is a separate registry from the in-memory `agents` map.
445
+ * After a plugin restart, the in-memory map is empty but the
446
+ * underlying PID may still be alive. The function probes the OS, so
447
+ * it answers the underlying question even when the agent record has
448
+ * been forgotten.
449
+ */
450
+ export function isAlive(processId: number): boolean {
451
+ if (!Number.isFinite(processId) || processId <= 0) return false;
452
+ try {
453
+ // Signal 0 = probe only. Returns true if the process exists and
454
+ // we have permission to signal it.
455
+ process.kill(processId, 0);
456
+ return true;
457
+ } catch {
458
+ return false;
459
+ }
460
+ }
461
+
376
462
  /**
377
463
  * List every tracked agent. Used by the plugin's startup to reconcile
378
464
  * state and by tests.
@@ -0,0 +1,77 @@
1
+ /**
2
+ * plugins/bizar/src/tools/bg-pause.ts
3
+ *
4
+ * v5.x — `bizar_pause` tool. Sends SIGSTOP to a running background
5
+ * agent's subprocess (POSIX only). The InstanceManager flips the
6
+ * in-memory status to `"paused"` and persists the change.
7
+ *
8
+ * The companion tool `bizar_resume` ({@link ./bg-resume.ts}) reverses
9
+ * the action via SIGCONT.
10
+ *
11
+ * Odin-only — same auth model as the rest of the bg tools.
12
+ */
13
+ import { tool } from "@opencode-ai/plugin";
14
+ import { z } from "zod";
15
+
16
+ import type { InstanceManager } from "../background.js";
17
+ import type { Logger } from "../logger.js";
18
+
19
+ export interface BgPauseDeps {
20
+ instanceManager: InstanceManager;
21
+ logger: Logger;
22
+ }
23
+
24
+ export function createBgPauseTool(deps: BgPauseDeps) {
25
+ return tool({
26
+ description:
27
+ "Pause a running background agent by sending SIGSTOP to its subprocess. " +
28
+ "Only Odin may pause. No-op on already-paused instances. " +
29
+ "POSIX only — on Windows this returns an `unsupported_on_win32` error.",
30
+ args: {
31
+ instanceId: z
32
+ .string()
33
+ .min(1)
34
+ .describe("Instance id returned by bizar_spawn_background."),
35
+ },
36
+ execute: async (rawArgs, ctx) => {
37
+ if (ctx.agent !== "odin") {
38
+ return {
39
+ output: JSON.stringify({
40
+ error:
41
+ "Only Odin can pause background agents. Use bizar_status to inspect or ask Odin to pause.",
42
+ }),
43
+ };
44
+ }
45
+ const args = rawArgs as { instanceId: string };
46
+ try {
47
+ const result = await deps.instanceManager.pause(args.instanceId);
48
+ if (!result.ok) {
49
+ return {
50
+ output: JSON.stringify({
51
+ error: result.error || "pause failed",
52
+ instanceId: args.instanceId,
53
+ }),
54
+ };
55
+ }
56
+ return {
57
+ output: JSON.stringify({
58
+ instanceId: args.instanceId,
59
+ status: "paused",
60
+ }),
61
+ };
62
+ } catch (err: unknown) {
63
+ deps.logger.warn(
64
+ `bizar: pause(${args.instanceId}) threw: ${
65
+ err instanceof Error ? err.message : String(err)
66
+ }`,
67
+ );
68
+ return {
69
+ output: JSON.stringify({
70
+ error: `pause threw: ${err instanceof Error ? err.message : String(err)}`,
71
+ instanceId: args.instanceId,
72
+ }),
73
+ };
74
+ }
75
+ },
76
+ });
77
+ }