@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.
- package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js → EnvVarsSection-L413CQEI.js} +3 -3
- package/bizar-dash/dist/assets/{EnvVarsSection-DqvB8ZcJ.js.map → EnvVarsSection-L413CQEI.js.map} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js → MobileChat-0kGQb1S6.js} +1 -1
- package/bizar-dash/dist/assets/{MobileChat-DRXP2ERQ.js.map → MobileChat-0kGQb1S6.js.map} +1 -1
- package/bizar-dash/dist/assets/MobileSettings-Cq1oFIdO.js +1 -0
- package/bizar-dash/dist/assets/{MobileSettings-CVW9VhEo.js.map → MobileSettings-Cq1oFIdO.js.map} +1 -1
- package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js → Toast-C6f1zFJ2.js} +1 -1
- package/bizar-dash/dist/assets/{Toast-CcZVVN4i.js.map → Toast-C6f1zFJ2.js.map} +1 -1
- package/bizar-dash/dist/assets/{icons-OMEGx5KA.js → icons-BsWXWkEF.js} +121 -126
- package/bizar-dash/dist/assets/icons-BsWXWkEF.js.map +1 -0
- package/bizar-dash/dist/assets/main-BklYy02e.css +1 -0
- package/bizar-dash/dist/assets/main-CBTgYSeG.js +18 -0
- package/bizar-dash/dist/assets/main-CBTgYSeG.js.map +1 -0
- package/bizar-dash/dist/assets/mobile-CsaN4CxH.js +1 -0
- package/bizar-dash/dist/assets/{mobile-XN2P7YfM.js.map → mobile-CsaN4CxH.js.map} +1 -1
- package/bizar-dash/dist/assets/{mobile-layout-D6V-7WnA.css → mobile-layout-CJnZNLy3.css} +1 -1
- package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js +2 -0
- package/bizar-dash/dist/assets/mobile-layout-iFBdjEE4.js.map +1 -0
- package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js → useSlashCommands-DGJcC9iQ.js} +2 -2
- package/bizar-dash/dist/assets/{useSlashCommands-iyoW95UB.js.map → useSlashCommands-DGJcC9iQ.js.map} +1 -1
- package/bizar-dash/dist/index.html +8 -8
- package/bizar-dash/dist/mobile.html +4 -4
- package/bizar-dash/node_modules/.vite/vitest/da39a3ee5e6b4b0d3255bfef95601890afd80709/results.json +1 -1
- package/bizar-dash/src/server/backup-store.mjs +6 -1
- package/bizar-dash/src/server/bg-spawner.mjs +574 -0
- package/bizar-dash/src/server/memory-lightrag.mjs +242 -0
- package/bizar-dash/src/server/memory-store.mjs +43 -3
- package/bizar-dash/src/server/routes/background.mjs +143 -0
- package/bizar-dash/src/server/routes/lightrag.mjs +10 -0
- package/bizar-dash/src/server/routes/memory.mjs +8 -0
- package/bizar-dash/src/server/server.mjs +22 -0
- package/bizar-dash/src/web/App.tsx +3 -0
- package/bizar-dash/src/web/MobileApp.tsx +1 -10
- package/bizar-dash/src/web/components/InstallConfirmDialog.tsx +1 -1
- package/bizar-dash/src/web/components/SettingsNav.tsx +1 -10
- package/bizar-dash/src/web/lib/types.ts +39 -1
- package/bizar-dash/src/web/styles/main.css +43 -0
- package/bizar-dash/src/web/styles/mobile-layout.css +7 -5
- package/bizar-dash/src/web/styles/settings.css +46 -0
- package/bizar-dash/src/web/views/BackgroundAgents.tsx +399 -30
- package/bizar-dash/src/web/views/Settings.tsx +12 -12
- package/bizar-dash/src/web/views/SpawnAgentModal.tsx +221 -0
- package/bizar-dash/tests/BackgroundAgents.test.tsx +160 -0
- package/bizar-dash/tests/background-pause-resume.test.mjs +46 -0
- package/bizar-dash/tests/background-spawn-from-ui.test.mjs +36 -0
- package/bizar-dash/tests/background-steer.test.mjs +42 -0
- package/bizar-dash/tests/lightrag-startup-hook.test.mjs +206 -0
- package/bizar-dash/tests/memory-auto-reindex.test.mjs +196 -0
- package/bizar-dash/tests/memory-cli.test.mjs +1 -1
- package/bizar-dash/tests/memory-default-vault.test.mjs +90 -3
- package/bizar-dash/tests/memory-lightrag-extended.test.mjs +131 -0
- package/bizar-dash/tests/mobile-layout.test.tsx +36 -0
- package/bizar-dash/tests/settings-layout.test.tsx +3 -3
- package/bizar-dash/tests/settings-nav.test.tsx +7 -5
- package/cli/bin.mjs +16 -0
- package/cli/commands/deploy/docker.mjs +2 -1
- package/cli/commands/lightrag.mjs +171 -0
- package/cli/commands/service.mjs +7 -0
- package/cli/commands/util.mjs +8 -1
- package/cli/init.mjs +1 -1
- package/cli/post-install-smoke.mjs +230 -0
- package/cli/provision.mjs +177 -0
- package/cli/provision.test.mjs +180 -0
- package/cli/service-controller.mjs +378 -28
- package/cli/service-controller.test.mjs +100 -0
- package/cli/service-env.mjs +139 -0
- package/cli/service.mjs +23 -0
- package/config/agents/_shared/AGENT_BASELINE.md +6 -6
- package/install.sh +87 -6
- package/package.json +1 -1
- package/plugins/bizar/index.ts +147 -0
- package/plugins/bizar/src/background-state.ts +84 -5
- package/plugins/bizar/src/background.ts +388 -10
- package/plugins/bizar/src/hooks/memory-inject.ts +247 -0
- package/plugins/bizar/src/hooks/memory-write-on-end.ts +188 -0
- package/plugins/bizar/src/opencode-runner.ts +87 -1
- package/plugins/bizar/src/tools/bg-pause.ts +77 -0
- package/plugins/bizar/src/tools/bg-report-progress.ts +101 -0
- package/plugins/bizar/src/tools/bg-resume.ts +72 -0
- package/plugins/bizar/src/tools/bg-send-message.ts +105 -0
- package/plugins/bizar/src/tools/bg-spawn.ts +13 -6
- package/plugins/bizar/src/tools/bg-status.ts +10 -0
- package/plugins/bizar/src/tools/memory-list.ts +135 -0
- package/plugins/bizar/src/tools/memory-read.ts +142 -0
- package/plugins/bizar/src/tools/memory-search.ts +228 -0
- package/plugins/bizar/src/tools/memory-write.ts +183 -0
- package/plugins/bizar/tests/memory-write-on-end.test.ts +92 -0
- package/plugins/bizar/tests/tools/bg-pause.test.ts +64 -0
- package/plugins/bizar/tests/tools/bg-report-progress.test.ts +82 -0
- package/plugins/bizar/tests/tools/bg-resume.test.ts +43 -0
- package/plugins/bizar/tests/tools/bg-send-message.test.ts +46 -0
- package/bizar-dash/dist/assets/MobileSettings-CVW9VhEo.js +0 -1
- package/bizar-dash/dist/assets/icons-OMEGx5KA.js.map +0 -1
- package/bizar-dash/dist/assets/main-CMAGZouj.js +0 -16
- package/bizar-dash/dist/assets/main-CMAGZouj.js.map +0 -1
- package/bizar-dash/dist/assets/main-O2tZVdHm.css +0 -1
- package/bizar-dash/dist/assets/mobile-XN2P7YfM.js +0 -1
- package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js +0 -2
- package/bizar-dash/dist/assets/mobile-layout-DqTxvXZp.js.map +0 -1
|
@@ -0,0 +1,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* src/server/bg-spawner.mjs
|
|
3
|
+
*
|
|
4
|
+
* v5.x — Dashboard-side background agent spawner.
|
|
5
|
+
*
|
|
6
|
+
* Bridges "spawn from UI" without requiring an LLM round-trip. Mirrors
|
|
7
|
+
* the plugin's `opencode-runner.ts` semantics as a Node-flavoured
|
|
8
|
+
* equivalent: spawns one `opencode run` subprocess per agent, captures
|
|
9
|
+
* stdout/stderr to a log file, tracks the PID, and broadcasts WS events
|
|
10
|
+
* for each new output line.
|
|
11
|
+
*
|
|
12
|
+
* Why the dashboard has its own spawner:
|
|
13
|
+
* - The plugin process (the opencode plugin) isn't always reachable
|
|
14
|
+
* from the dashboard. Spawning from UI must work even when the
|
|
15
|
+
* user is operating the dashboard before/after an opencode session.
|
|
16
|
+
* - The dashboard already has its own pid space and state directory;
|
|
17
|
+
* this module inherits the existing `~/.cache/bizar/bg/*.json`
|
|
18
|
+
* layout so the dashboard list view picks up the new instance
|
|
19
|
+
* immediately.
|
|
20
|
+
*
|
|
21
|
+
* Public surface:
|
|
22
|
+
* - `spawnBgAgent({...})` — create instance + subprocess, return
|
|
23
|
+
* `{ instanceId, sessionId?, processId? }`.
|
|
24
|
+
* - `killBgAgent(instanceId, { signal })`, `pauseBgAgent`,
|
|
25
|
+
* `resumeBgAgent` — control signals. POSIX-only for signal
|
|
26
|
+
* variants; Windows returns an explicit error.
|
|
27
|
+
* - `isAlive(instanceId)` — liveness check for an instance PID.
|
|
28
|
+
* - `status()` — diagnostic dump (process count, ids).
|
|
29
|
+
*
|
|
30
|
+
* State file: `~/.cache/bizar/bg/<instanceId>.json` — same shape
|
|
31
|
+
* the plugin uses, kept compatible. We update the file as the
|
|
32
|
+
* subprocess progresses (status flips, tool calls recorded, etc.).
|
|
33
|
+
*/
|
|
34
|
+
import { spawn } from 'node:child_process';
|
|
35
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, appendFileSync, statSync, readdirSync, unlinkSync } from 'node:fs';
|
|
36
|
+
import { randomBytes } from 'node:crypto';
|
|
37
|
+
import { homedir } from 'node:os';
|
|
38
|
+
import { resolve as pathResolve, dirname } from 'node:path';
|
|
39
|
+
|
|
40
|
+
/** Same shape the plugin uses for instance IDs. */
|
|
41
|
+
function generateInstanceId() {
|
|
42
|
+
const bytes = randomBytes(16);
|
|
43
|
+
// crockford base32: 26 chars total, take 22
|
|
44
|
+
const ALPH = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
|
|
45
|
+
let bits = 0;
|
|
46
|
+
let value = 0;
|
|
47
|
+
let out = '';
|
|
48
|
+
for (let i = 0; i < bytes.length; i++) {
|
|
49
|
+
value = (value << 8) | bytes[i];
|
|
50
|
+
bits += 8;
|
|
51
|
+
while (bits >= 5) {
|
|
52
|
+
out += ALPH[(value >>> (bits - 5)) & 0x1f];
|
|
53
|
+
bits -= 5;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (bits > 0) out += ALPH[(value << (5 - bits)) & 0x1f];
|
|
57
|
+
return 'bgr_' + out.slice(0, 22);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// --- Configuration -------------------------------------------------------
|
|
61
|
+
|
|
62
|
+
const HOME = homedir();
|
|
63
|
+
const BG_DIR_CANDIDATES = [
|
|
64
|
+
pathResolve(HOME, '.cache', 'bizar', 'bg'),
|
|
65
|
+
pathResolve(HOME, '.config', 'opencode', 'bg'),
|
|
66
|
+
pathResolve(HOME, '.bizar', 'bg'),
|
|
67
|
+
];
|
|
68
|
+
const LOG_DIR_CANDIDATES = [
|
|
69
|
+
process.env.BIZAR_LOG_DIR || pathResolve(HOME, '.cache', 'bizar', 'logs'),
|
|
70
|
+
];
|
|
71
|
+
|
|
72
|
+
function pickBgDir() {
|
|
73
|
+
for (const dir of BG_DIR_CANDIDATES) {
|
|
74
|
+
if (existsSync(dir)) return dir;
|
|
75
|
+
}
|
|
76
|
+
return BG_DIR_CANDIDATES[0];
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function pickLogDir() {
|
|
80
|
+
for (const dir of LOG_DIR_CANDIDATES) {
|
|
81
|
+
if (existsSync(dir)) return dir;
|
|
82
|
+
}
|
|
83
|
+
const def = LOG_DIR_CANDIDATES[0];
|
|
84
|
+
try {
|
|
85
|
+
mkdirSync(def, { recursive: true });
|
|
86
|
+
} catch {
|
|
87
|
+
/* ignore */
|
|
88
|
+
}
|
|
89
|
+
return def;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// --- In-memory subprocess registry --------------------------------------
|
|
93
|
+
|
|
94
|
+
/**
|
|
95
|
+
* @typedef {object} SpawnerRecord
|
|
96
|
+
* @property {number} processId
|
|
97
|
+
* @property {string} instanceId
|
|
98
|
+
* @property {string} sessionId
|
|
99
|
+
* @property {string} logPath
|
|
100
|
+
* @property {import('node:child_process').ChildProcess} proc
|
|
101
|
+
* @property {string} state — "starting" | "running" | "paused" | "done" | "failed" | "killed"
|
|
102
|
+
* @property {number} startedAt
|
|
103
|
+
* @property {number} [endedAt]
|
|
104
|
+
* @property {number} [exitCode]
|
|
105
|
+
* @property {string} [worktree]
|
|
106
|
+
*/
|
|
107
|
+
|
|
108
|
+
/** @type {Map<string, SpawnerRecord>} — keyed by instanceId */
|
|
109
|
+
const byInstanceId = new Map();
|
|
110
|
+
/** @type {Map<number, SpawnerRecord>} — keyed by processId */
|
|
111
|
+
const byPid = new Map();
|
|
112
|
+
|
|
113
|
+
// --- Public API ---------------------------------------------------------
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* @typedef {object} BroadcastFn
|
|
117
|
+
* @property {(msg: object) => void} [broadcast]
|
|
118
|
+
*/
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* @param {BroadcastFn} ctx
|
|
122
|
+
*/
|
|
123
|
+
export function configureSpawner(ctx) {
|
|
124
|
+
state.broadcast = ctx.broadcast || (() => {});
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Spawn one opencode run subprocess. Mirrors the plugin's
|
|
129
|
+
* `bg-spawn.ts` semantics. Writes the initial BackgroundState JSON,
|
|
130
|
+
* starts the subprocess, then patches the JSON when the session id
|
|
131
|
+
* appears in stderr.
|
|
132
|
+
*
|
|
133
|
+
* @param {object} opts
|
|
134
|
+
* @param {string} opts.agent
|
|
135
|
+
* @param {string} opts.prompt
|
|
136
|
+
* @param {{providerID: string, modelID: string}} [opts.model]
|
|
137
|
+
* @param {string} opts.worktree
|
|
138
|
+
* @param {number} [opts.timeoutMs]
|
|
139
|
+
* @param {boolean} [opts.persistent]
|
|
140
|
+
* @param {number} [opts.maxRestarts]
|
|
141
|
+
* @param {string[]} [opts.tags]
|
|
142
|
+
* @returns {Promise<{instanceId: string, sessionId: string|null, processId: number|null, error?: string}>}
|
|
143
|
+
*/
|
|
144
|
+
export async function spawnBgAgent(opts) {
|
|
145
|
+
if (!opts || !opts.agent || !opts.prompt || !opts.worktree) {
|
|
146
|
+
return { instanceId: '', sessionId: null, processId: null, error: 'missing_required_fields' };
|
|
147
|
+
}
|
|
148
|
+
const instanceId = generateInstanceId();
|
|
149
|
+
const logDir = pickLogDir();
|
|
150
|
+
const logPath = pathResolve(logDir, `${instanceId}.log`);
|
|
151
|
+
const now = Date.now();
|
|
152
|
+
/** @type {BackgroundState} */
|
|
153
|
+
const initial = {
|
|
154
|
+
instanceId,
|
|
155
|
+
sessionId: '',
|
|
156
|
+
agent: opts.agent,
|
|
157
|
+
model: opts.model
|
|
158
|
+
? `${opts.model.providerID}/${opts.model.modelID}`
|
|
159
|
+
: 'agent-default',
|
|
160
|
+
promptPreview: String(opts.prompt).slice(0, 200),
|
|
161
|
+
prompt: String(opts.prompt),
|
|
162
|
+
parentAgent: 'dashboard',
|
|
163
|
+
logPath,
|
|
164
|
+
timeoutMs: Math.max(1000, Math.floor(opts.timeoutMs ?? 300_000)),
|
|
165
|
+
toolCallCount: 0,
|
|
166
|
+
status: 'pending',
|
|
167
|
+
startedAt: now,
|
|
168
|
+
lastEventAt: now,
|
|
169
|
+
lastToolOrTextAt: now,
|
|
170
|
+
interventionCount: 0,
|
|
171
|
+
persistent: Boolean(opts.persistent),
|
|
172
|
+
maxRestarts: Math.max(1, Math.floor(opts.maxRestarts ?? 3)),
|
|
173
|
+
restartCount: 0,
|
|
174
|
+
runnerState: 'starting',
|
|
175
|
+
spawnedAt: now,
|
|
176
|
+
progress: 0,
|
|
177
|
+
toolCalls: [],
|
|
178
|
+
tags: Array.isArray(opts.tags) ? opts.tags.slice(0, 10) : undefined,
|
|
179
|
+
source: 'dashboard',
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
await writeStateFile(initial);
|
|
183
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'pending', source: 'dashboard' });
|
|
184
|
+
|
|
185
|
+
// Build argv — mirror plugins/bizar/src/opencode-runner.ts buildOpencodeRunArgs.
|
|
186
|
+
const args = [
|
|
187
|
+
'opencode',
|
|
188
|
+
'run',
|
|
189
|
+
'--dir', opts.worktree,
|
|
190
|
+
'--print-logs',
|
|
191
|
+
'--log-level', 'INFO',
|
|
192
|
+
'--title', `bgr:${opts.agent}:${instanceId}`,
|
|
193
|
+
'--agent', opts.agent,
|
|
194
|
+
];
|
|
195
|
+
if (opts.model) args.push('--model', `${opts.model.providerID}/${opts.model.modelID}`);
|
|
196
|
+
args.push('--', String(opts.prompt));
|
|
197
|
+
|
|
198
|
+
// For subagents, wrap with delegation prompt (mirroring bg-spawn.ts).
|
|
199
|
+
const PRIMARY = new Set(['odin', 'quick', 'browser-harness']);
|
|
200
|
+
let finalPrompt = String(opts.prompt);
|
|
201
|
+
let wrapperAgent = opts.agent;
|
|
202
|
+
if (!PRIMARY.has(opts.agent)) {
|
|
203
|
+
wrapperAgent = 'odin';
|
|
204
|
+
finalPrompt = [
|
|
205
|
+
'You are Odin, the BizarHarness router.',
|
|
206
|
+
'',
|
|
207
|
+
'A background agent session has been requested with a SPECIFIC subagent.',
|
|
208
|
+
'Your only job is to delegate to that subagent using the `task` tool. Do NOT',
|
|
209
|
+
'perform the work yourself. Do NOT interpret the user\'s prompt.',
|
|
210
|
+
'',
|
|
211
|
+
`Requested subagent: ${opts.agent}`,
|
|
212
|
+
'',
|
|
213
|
+
'Task prompt to pass verbatim:',
|
|
214
|
+
'--- BEGIN USER PROMPT ---',
|
|
215
|
+
String(opts.prompt),
|
|
216
|
+
'--- END USER PROMPT ---',
|
|
217
|
+
'',
|
|
218
|
+
`Use the task tool with agent="${opts.agent}" and the exact prompt above.`,
|
|
219
|
+
].join('\n');
|
|
220
|
+
args[args.indexOf('--agent') + 1] = wrapperAgent;
|
|
221
|
+
// Replace the trailing "-- <prompt>" with the wrapped prompt.
|
|
222
|
+
const dashIdx = args.indexOf('--', args.indexOf('--agent'));
|
|
223
|
+
if (dashIdx >= 0) {
|
|
224
|
+
args.splice(dashIdx + 1, 1, finalPrompt);
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
let proc;
|
|
229
|
+
try {
|
|
230
|
+
proc = spawn(args[0], args.slice(1), {
|
|
231
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
232
|
+
env: { ...process.env },
|
|
233
|
+
});
|
|
234
|
+
} catch (err) {
|
|
235
|
+
await patchState(instanceId, {
|
|
236
|
+
status: 'failed',
|
|
237
|
+
error: `spawn failed: ${err instanceof Error ? err.message : String(err)}`,
|
|
238
|
+
completedAt: Date.now(),
|
|
239
|
+
});
|
|
240
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'failed', error: err.message });
|
|
241
|
+
return { instanceId, sessionId: null, processId: null, error: err.message };
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** @type {SpawnerRecord} */
|
|
245
|
+
const rec = {
|
|
246
|
+
processId: proc.pid,
|
|
247
|
+
instanceId,
|
|
248
|
+
sessionId: '',
|
|
249
|
+
logPath,
|
|
250
|
+
proc,
|
|
251
|
+
state: 'starting',
|
|
252
|
+
startedAt: Date.now(),
|
|
253
|
+
worktree: opts.worktree,
|
|
254
|
+
};
|
|
255
|
+
byInstanceId.set(instanceId, rec);
|
|
256
|
+
byPid.set(proc.pid, rec);
|
|
257
|
+
|
|
258
|
+
const sessionIdRegex = /message=created id=(ses_[A-Za-z0-9_]+)/;
|
|
259
|
+
let buf = '';
|
|
260
|
+
let appendOffset = 0;
|
|
261
|
+
|
|
262
|
+
// Helper: append a line to the log file AND broadcast ws event.
|
|
263
|
+
const emit = (label, chunk) => {
|
|
264
|
+
if (!chunk) return;
|
|
265
|
+
const text = chunk.toString('utf-8');
|
|
266
|
+
appendFileSync(logPath, `[${label}] ${text}`);
|
|
267
|
+
appendOffset += Buffer.byteLength(text, 'utf-8');
|
|
268
|
+
// Per-line broadcast: split on newlines.
|
|
269
|
+
for (const line of text.split(/\r?\n/)) {
|
|
270
|
+
if (!line) continue;
|
|
271
|
+
broadcast({
|
|
272
|
+
type: 'bg:output',
|
|
273
|
+
instanceId,
|
|
274
|
+
line,
|
|
275
|
+
ts: Date.now(),
|
|
276
|
+
byteOffset: appendOffset,
|
|
277
|
+
stream: label,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
};
|
|
281
|
+
|
|
282
|
+
proc.stdout.on('data', (chunk) => emit('stdout', chunk));
|
|
283
|
+
proc.stderr.on('data', (chunk) => {
|
|
284
|
+
emit('stderr', chunk);
|
|
285
|
+
const text = chunk.toString('utf-8');
|
|
286
|
+
buf += text;
|
|
287
|
+
const m = buf.match(sessionIdRegex);
|
|
288
|
+
if (m && m[1] && !rec.sessionId) {
|
|
289
|
+
rec.sessionId = m[1];
|
|
290
|
+
const sid = m[1];
|
|
291
|
+
patchState(instanceId, {
|
|
292
|
+
sessionId: sid,
|
|
293
|
+
status: 'running',
|
|
294
|
+
runnerState: 'running',
|
|
295
|
+
processId: proc.pid,
|
|
296
|
+
sessionIdAt: Date.now(),
|
|
297
|
+
}).then(() => {
|
|
298
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'running', sessionId: sid });
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
});
|
|
302
|
+
|
|
303
|
+
proc.on('exit', async (code, signal) => {
|
|
304
|
+
rec.state = signal === 'SIGTERM' || signal === 'SIGKILL' ? 'killed' : (code === 0 ? 'done' : 'failed');
|
|
305
|
+
rec.endedAt = Date.now();
|
|
306
|
+
rec.exitCode = code ?? undefined;
|
|
307
|
+
byPid.delete(proc.pid);
|
|
308
|
+
const wasAdopted = false;
|
|
309
|
+
const update = {
|
|
310
|
+
runnerState: rec.state,
|
|
311
|
+
completedAt: rec.endedAt,
|
|
312
|
+
...(code != null ? { exitCode: code } : {}),
|
|
313
|
+
};
|
|
314
|
+
if (signal) update.exitSignal = signal;
|
|
315
|
+
// Don't clobber "steered" status.
|
|
316
|
+
const cur = readStateFile(instanceId);
|
|
317
|
+
if (cur && cur.status !== 'steered' && cur.status !== 'killed' && cur.status !== 'failed') {
|
|
318
|
+
update.status = rec.state === 'killed' ? 'killed' : rec.state === 'done' ? 'done' : 'failed';
|
|
319
|
+
}
|
|
320
|
+
await patchState(instanceId, update);
|
|
321
|
+
broadcast({
|
|
322
|
+
type: 'background:change',
|
|
323
|
+
id: instanceId,
|
|
324
|
+
status: update.status,
|
|
325
|
+
processId: proc.pid,
|
|
326
|
+
exitCode: code,
|
|
327
|
+
...(rec.sessionId ? { sessionId: rec.sessionId } : {}),
|
|
328
|
+
});
|
|
329
|
+
void wasAdopted;
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
proc.on('error', (err) => {
|
|
333
|
+
broadcast({ type: 'bg:error', instanceId, error: err.message });
|
|
334
|
+
});
|
|
335
|
+
|
|
336
|
+
return { instanceId, sessionId: null, processId: proc.pid };
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
/**
|
|
340
|
+
* Kill an instance's subprocess. Sends SIGTERM, then SIGKILL after 5s.
|
|
341
|
+
* @param {string} instanceId
|
|
342
|
+
* @param {{signal?: 'SIGTERM' | 'SIGKILL', reason?: string}} [opts]
|
|
343
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
344
|
+
*/
|
|
345
|
+
export async function killBgAgent(instanceId, opts = {}) {
|
|
346
|
+
const rec = byInstanceId.get(instanceId);
|
|
347
|
+
if (!rec) return { ok: false, error: 'instance_not_tracked' };
|
|
348
|
+
if (rec.endedAt) return { ok: true, note: 'already_exited' };
|
|
349
|
+
const signal = opts.signal || 'SIGTERM';
|
|
350
|
+
try {
|
|
351
|
+
rec.proc.kill(signal);
|
|
352
|
+
rec.state = 'killed';
|
|
353
|
+
if (rec.sessionId) {
|
|
354
|
+
await patchState(instanceId, {
|
|
355
|
+
status: 'killed',
|
|
356
|
+
runnerState: 'killed',
|
|
357
|
+
completedAt: Date.now(),
|
|
358
|
+
exitSignal: signal,
|
|
359
|
+
});
|
|
360
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'killed', reason: opts.reason });
|
|
361
|
+
}
|
|
362
|
+
setTimeout(() => {
|
|
363
|
+
if (!rec.endedAt) {
|
|
364
|
+
try { rec.proc.kill('SIGKILL'); } catch { /* ignore */ }
|
|
365
|
+
}
|
|
366
|
+
}, 5_000);
|
|
367
|
+
return { ok: true };
|
|
368
|
+
} catch (err) {
|
|
369
|
+
return { ok: false, error: err instanceof Error ? err.message : String(err) };
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
/**
|
|
374
|
+
* Pause an instance: SIGSTOP the subprocess.
|
|
375
|
+
* @param {string} instanceId
|
|
376
|
+
* @returns {{ ok: boolean, error?: string }}
|
|
377
|
+
*/
|
|
378
|
+
export function pauseBgAgent(instanceId) {
|
|
379
|
+
if (process.platform === 'win32') {
|
|
380
|
+
return { ok: false, error: 'unsupported_on_win32' };
|
|
381
|
+
}
|
|
382
|
+
const rec = byInstanceId.get(instanceId);
|
|
383
|
+
if (!rec) return { ok: false, error: 'instance_not_tracked' };
|
|
384
|
+
if (rec.endedAt) return { ok: true, note: 'already_exited' };
|
|
385
|
+
if (rec.state === 'paused') return { ok: true };
|
|
386
|
+
try {
|
|
387
|
+
rec.proc.kill('SIGSTOP');
|
|
388
|
+
rec.state = 'paused';
|
|
389
|
+
patchState(instanceId, {
|
|
390
|
+
status: 'paused',
|
|
391
|
+
pausedAt: Date.now(),
|
|
392
|
+
}).then(() => {
|
|
393
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'paused' });
|
|
394
|
+
});
|
|
395
|
+
return { ok: true };
|
|
396
|
+
} catch (err) {
|
|
397
|
+
return { ok: false, error: err.message };
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
|
|
401
|
+
/**
|
|
402
|
+
* Resume an instance: SIGCONT.
|
|
403
|
+
* @param {string} instanceId
|
|
404
|
+
*/
|
|
405
|
+
export function resumeBgAgent(instanceId) {
|
|
406
|
+
if (process.platform === 'win32') {
|
|
407
|
+
return { ok: false, error: 'unsupported_on_win32' };
|
|
408
|
+
}
|
|
409
|
+
const rec = byInstanceId.get(instanceId);
|
|
410
|
+
if (!rec) return { ok: false, error: 'instance_not_tracked' };
|
|
411
|
+
if (rec.endedAt) return { ok: true, note: 'already_exited' };
|
|
412
|
+
if (rec.state !== 'paused') return { ok: true };
|
|
413
|
+
try {
|
|
414
|
+
rec.proc.kill('SIGCONT');
|
|
415
|
+
rec.state = 'running';
|
|
416
|
+
patchState(instanceId, {
|
|
417
|
+
status: 'running',
|
|
418
|
+
pausedAt: undefined,
|
|
419
|
+
}).then(() => {
|
|
420
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'running' });
|
|
421
|
+
});
|
|
422
|
+
return { ok: true };
|
|
423
|
+
} catch (err) {
|
|
424
|
+
return { ok: false, error: err.message };
|
|
425
|
+
}
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
/**
|
|
429
|
+
* Steer an instance by killing it and spawning a new one with the
|
|
430
|
+
* original prompt + a [STEERED ...] marker + the user's message
|
|
431
|
+
* appended. Returns the new instance id.
|
|
432
|
+
*
|
|
433
|
+
* @param {string} instanceId
|
|
434
|
+
* @param {string} message
|
|
435
|
+
* @returns {Promise<{ ok: boolean, newInstanceId?: string, error?: string }>}
|
|
436
|
+
*/
|
|
437
|
+
export async function steerBgAgent(instanceId, message) {
|
|
438
|
+
if (!message || !message.trim()) {
|
|
439
|
+
return { ok: false, error: 'message_empty' };
|
|
440
|
+
}
|
|
441
|
+
const cur = readStateFile(instanceId);
|
|
442
|
+
if (!cur) return { ok: false, error: 'instance_not_found' };
|
|
443
|
+
// Persist the steered-from state.
|
|
444
|
+
await patchState(instanceId, {
|
|
445
|
+
status: 'steered',
|
|
446
|
+
completedAt: Date.now(),
|
|
447
|
+
steeredFromMessage: message.slice(0, 500),
|
|
448
|
+
steeredAt: Date.now(),
|
|
449
|
+
});
|
|
450
|
+
broadcast({ type: 'background:change', id: instanceId, status: 'steered' });
|
|
451
|
+
// Kill the current subprocess (best-effort — the new one will
|
|
452
|
+
// continue regardless; we don't want to block).
|
|
453
|
+
void killBgAgent(instanceId, { signal: 'SIGTERM', reason: 'steered' });
|
|
454
|
+
// Spawn the steered replacement.
|
|
455
|
+
const combined = [
|
|
456
|
+
cur.prompt || cur.promptPreview || '',
|
|
457
|
+
'',
|
|
458
|
+
`[STEERED ${new Date().toISOString()}]`,
|
|
459
|
+
message.trim(),
|
|
460
|
+
].join('\n');
|
|
461
|
+
const res = await spawnBgAgent({
|
|
462
|
+
agent: cur.agent,
|
|
463
|
+
prompt: combined,
|
|
464
|
+
model: cur.model && cur.model !== 'agent-default'
|
|
465
|
+
? { providerID: cur.model.split('/')[0], modelID: cur.model.split('/').slice(1).join('/') }
|
|
466
|
+
: undefined,
|
|
467
|
+
worktree: cur.worktree || process.cwd(),
|
|
468
|
+
persistent: cur.persistent,
|
|
469
|
+
maxRestarts: cur.maxRestarts,
|
|
470
|
+
timeoutMs: cur.timeoutMs,
|
|
471
|
+
tags: cur.tags,
|
|
472
|
+
});
|
|
473
|
+
if (res.error) return { ok: false, error: res.error };
|
|
474
|
+
// Link the new instance to the old one in the state file.
|
|
475
|
+
if (res.instanceId) {
|
|
476
|
+
patchState(res.instanceId, { parentInstanceId: instanceId }).catch(() => {});
|
|
477
|
+
}
|
|
478
|
+
return { ok: true, newInstanceId: res.instanceId, processId: res.processId };
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
/**
|
|
482
|
+
* Liveness probe.
|
|
483
|
+
* @param {string} instanceId
|
|
484
|
+
* @returns {boolean}
|
|
485
|
+
*/
|
|
486
|
+
export function isAlive(instanceId) {
|
|
487
|
+
const rec = byInstanceId.get(instanceId);
|
|
488
|
+
if (!rec) return false;
|
|
489
|
+
return !rec.endedAt;
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
/** Diagnostics. */
|
|
493
|
+
export function status() {
|
|
494
|
+
return {
|
|
495
|
+
count: byInstanceId.size,
|
|
496
|
+
ids: Array.from(byInstanceId.keys()),
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
// --- Internal helpers ----------------------------------------------------
|
|
501
|
+
|
|
502
|
+
const state = {
|
|
503
|
+
/** @type {(msg: object) => void} */
|
|
504
|
+
broadcast: () => {},
|
|
505
|
+
};
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* @param {object} msg
|
|
509
|
+
*/
|
|
510
|
+
function broadcast(msg) {
|
|
511
|
+
try { state.broadcast(msg); } catch { /* ignore */ }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function bgDir() {
|
|
515
|
+
return pickBgDir();
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
function stateFile(instanceId) {
|
|
519
|
+
return pathResolve(bgDir(), `${instanceId}.json`);
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
/**
|
|
523
|
+
* Atomic-ish write: write to <file>.tmp then rename.
|
|
524
|
+
* @param {object} bgState
|
|
525
|
+
*/
|
|
526
|
+
async function writeStateFile(bgState) {
|
|
527
|
+
const dir = bgDir();
|
|
528
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
529
|
+
const file = stateFile(bgState.instanceId);
|
|
530
|
+
const tmp = `${file}.tmp`;
|
|
531
|
+
writeFileSync(tmp, JSON.stringify(bgState, null, 2), 'utf-8');
|
|
532
|
+
try {
|
|
533
|
+
const fs = await import('node:fs');
|
|
534
|
+
fs.renameSync(tmp, file);
|
|
535
|
+
} catch {
|
|
536
|
+
writeFileSync(file, JSON.stringify(bgState, null, 2), 'utf-8');
|
|
537
|
+
}
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
/**
|
|
541
|
+
* Read+modify+write the state file. Best-effort; logs on failure.
|
|
542
|
+
* @param {string} instanceId
|
|
543
|
+
* @param {object} patch
|
|
544
|
+
*/
|
|
545
|
+
async function patchState(instanceId, patch) {
|
|
546
|
+
const file = stateFile(instanceId);
|
|
547
|
+
if (!existsSync(file)) return;
|
|
548
|
+
try {
|
|
549
|
+
const cur = JSON.parse(readFileSync(file, 'utf-8') || '{}');
|
|
550
|
+
const next = { ...cur, ...patch };
|
|
551
|
+
writeFileSync(file, JSON.stringify(next, null, 2), 'utf-8');
|
|
552
|
+
} catch (err) {
|
|
553
|
+
broadcast({ type: 'bg:error', instanceId, error: `state_patch failed: ${err.message}` });
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/**
|
|
558
|
+
* @param {string} instanceId
|
|
559
|
+
* @returns {object|null}
|
|
560
|
+
*/
|
|
561
|
+
function readStateFile(instanceId) {
|
|
562
|
+
const file = stateFile(instanceId);
|
|
563
|
+
if (!existsSync(file)) return null;
|
|
564
|
+
try {
|
|
565
|
+
return JSON.parse(readFileSync(file, 'utf-8'));
|
|
566
|
+
} catch {
|
|
567
|
+
return null;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
void dirname;
|
|
572
|
+
void statSync;
|
|
573
|
+
void readdirSync;
|
|
574
|
+
void unlinkSync;
|