@yeaft/webchat-agent 0.1.1105 → 0.1.1107

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.
@@ -12,16 +12,18 @@
12
12
  * Plus the D1 bootstrap helper:
13
13
  * ensureDefaultSessionIfEmpty(yeaftDir, {libDir}) — if NO session exists on
14
14
  * disk, seed `session_default` with roster = every VP in the library, and
15
- * defaultVpId = alphabetically first vpId. No-op when ≥1 session present.
15
+ * defaultVpId = `omni` when present, otherwise the alphabetically first vpId.
16
+ * No-op when ≥1 session present.
16
17
  *
17
18
  * Hard constraints (PM):
18
19
  * (a) We don't touch 334o storage primitives (storage/index.js) — we call
19
20
  * group-store.openSession / saveMeta which already go through openLog.
20
21
  * (b) We don't touch VP entity (vp-store.js / vp-loader.js) — only read
21
22
  * via scanVpLibrary to know which VPs exist at seed time.
22
- * (c) When `addMember` is called with an empty roster and no defaultVpId
23
- * resolvable, callers surface `no_default_vp` via `createSessionFromSpec`;
24
- * on `removeMember` we permit the empty state (UI nudges the user).
23
+ * (c) `createSessionFromSpec` seeds omitted/empty rosters with the default
24
+ * generalist VP when the library has one; truly empty VP libraries can
25
+ * still create empty sessions and surface `no_default_vp` on first send.
26
+ * On `removeMember` we permit the empty state (UI nudges the user).
25
27
  *
26
28
  * Error shape — every throw is a `SessionCrudError` with a stable `.code`:
27
29
  * 'not_found' — group id has no dir / meta
@@ -98,6 +100,7 @@ export class SessionCrudError extends Error {
98
100
  }
99
101
 
100
102
  const GROUP_WORKDIR_REGISTRY = 'group-workdirs.json';
103
+ const DEFAULT_VP_ID = 'omni';
101
104
 
102
105
  export function sessionsRoot(yeaftDir) {
103
106
  return join(yeaftDir, 'sessions');
@@ -260,11 +263,24 @@ export function makeSessionId(name) {
260
263
  return nextSessionId(slug);
261
264
  }
262
265
 
266
+ function preferDefaultVp(vpIds) {
267
+ if (!Array.isArray(vpIds) || vpIds.length === 0) return null;
268
+ return vpIds.includes(DEFAULT_VP_ID) ? DEFAULT_VP_ID : vpIds[0];
269
+ }
270
+
271
+ function scanSortedVpIds(libDir) {
272
+ const vpIds = scanVpLibrary({ dir: libDir })
273
+ .map(v => v && v.id)
274
+ .filter(v => typeof v === 'string' && v.length > 0);
275
+ vpIds.sort((a, b) => a.localeCompare(b));
276
+ return vpIds;
277
+ }
278
+
263
279
  /**
264
280
  * (B) D1 seed — called at boot (or when multi-VP is first enabled). Idempotent:
265
281
  * returns `{seeded:false}` if any session already exists on disk (including
266
282
  * `session_default`). When empty, seeds with roster = full VP library, sorted
267
- * alphabetically; defaultVpId = roster[0].
283
+ * alphabetically; defaultVpId = `omni` when present, otherwise roster[0].
268
284
  *
269
285
  * When the VP library is also empty, we still seed an empty-roster session so
270
286
  * the UI has somewhere to land — but defaultVpId is null and downstream
@@ -278,14 +294,12 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
278
294
  return { seeded: false, sessionId: existing[0].id };
279
295
  }
280
296
 
281
- // Sort VP ids alphabetically (stable for tests / deterministic UI).
282
- // NB: vp-store returns records with `.id` (not `.vpId`) keep this in sync.
283
- const vps = scanVpLibrary({ dir: libDir })
284
- .map(v => v && v.id)
285
- .filter(v => typeof v === 'string' && v.length > 0);
286
- vps.sort((a, b) => a.localeCompare(b));
297
+ // Sort VP ids alphabetically (stable for tests / deterministic UI), but
298
+ // prefer the generalist Omni VP as the default when present so first-run
299
+ // sessions land on a useful assistant instead of an arbitrary first id.
300
+ const vps = scanSortedVpIds(libDir);
287
301
 
288
- const defaultVpId = vps[0] || null;
302
+ const defaultVpId = preferDefaultVp(vps);
289
303
  const { group, created } = seedDefaultSession(yeaftDir, {
290
304
  name: options.name || 'Default',
291
305
  roster: vps,
@@ -301,21 +315,27 @@ export function ensureDefaultSessionIfEmpty(yeaftDir, options = {}) {
301
315
  }
302
316
 
303
317
  /**
304
- * (A.1) Create group from a wizard spec. `spec.roster` is authoritative
305
- * we do NOT auto-expand to the full VP library here. That's D1's job only.
318
+ * (A.1) Create session from a wizard spec. `spec.roster` is authoritative
319
+ * when non-empty. If the caller omits a roster, seed the session with the
320
+ * default generalist VP (`omni`) when it exists so a new Session is usable
321
+ * immediately instead of opening with an empty roster.
306
322
  *
307
323
  * @param {string} yeaftDir
308
324
  * @param {{name:string, roster?:string[], defaultVpId?:string|null, workDir?:string}} spec
309
325
  * @returns {{id:string, name:string, roster:string[], defaultVpId:string|null, workDir?:string}}
310
326
  */
311
327
  export function createSessionFromSpec(yeaftDir, spec, options = {}) {
312
- const normalizedWorkDir = normalizeWorkDir(spec && spec.workDir);
328
+ const input = spec || {};
329
+ const normalizedWorkDir = normalizeWorkDir(input.workDir);
313
330
  const groupYeaftDir = normalizedWorkDir ? yeaftDirForWorkDir(normalizedWorkDir) : yeaftDir;
314
331
  const memoryRoot = options.memoryRoot || (groupYeaftDir ? join(groupYeaftDir, 'memory') : DEFAULT_MEMORY_ROOT);
315
- const name = String(spec && spec.name || '').trim();
332
+ const libDir = options.libDir || DEFAULT_VP_LIB_DIR;
333
+ const name = String(input.name || '').trim();
316
334
  if (!name) throw new SessionCrudError('invalid_name', null, 'group name required');
317
335
 
318
- const roster = Array.isArray(spec.roster) ? spec.roster.slice() : [];
336
+ const callerRoster = Array.isArray(input.roster) ? input.roster.slice() : [];
337
+ const fallbackVpId = callerRoster.length > 0 ? null : preferDefaultVp(scanSortedVpIds(libDir));
338
+ const roster = callerRoster.length > 0 ? callerRoster : (fallbackVpId ? [fallbackVpId] : []);
319
339
  // Validate every member up-front so we fail before touching fs.
320
340
  for (const vpId of roster) {
321
341
  if (isReservedVpId(vpId)) {
@@ -325,10 +345,9 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
325
345
  if (!v.ok) throw new SessionCrudError(v.reason, null, `invalid vpId: ${vpId}`);
326
346
  }
327
347
 
328
- // defaultVpId resolution: explicit > roster[0] > null. Null is allowed at
329
- // create time (empty roster) the wizard modal warns the user downstream
330
- // (task-334m spec: `no_default_vp` surfaced on first send, not on create).
331
- let defaultVpId = spec.defaultVpId || null;
348
+ // defaultVpId resolution: explicit > roster[0] > null. Null is only
349
+ // possible when both caller roster and VP library are empty.
350
+ let defaultVpId = input.defaultVpId || null;
332
351
  if (defaultVpId && !roster.includes(defaultVpId)) {
333
352
  throw new SessionCrudError('default_not_in_roster', null, `${defaultVpId} not in roster`);
334
353
  }
@@ -354,8 +373,8 @@ export function createSessionFromSpec(yeaftDir, spec, options = {}) {
354
373
  // turn.
355
374
  try {
356
375
  ensureSessionConfigFile(yeaftDir, id);
357
- if (spec && spec.config && typeof spec.config === 'object') {
358
- saveSessionConfig(yeaftDir, id, spec.config);
376
+ if (input.config && typeof input.config === 'object') {
377
+ saveSessionConfig(yeaftDir, id, input.config);
359
378
  }
360
379
  } catch (err) {
361
380
  console.warn(`[session-crud] failed to seed config.json for ${id}:`, err?.message || err);
@@ -0,0 +1,88 @@
1
+ /**
2
+ * systemd-scope.js — run child processes outside the agent service cgroup.
3
+ *
4
+ * When yeaft-agent runs as a systemd user service, shell tasks inherit the
5
+ * yeaft-agent.service cgroup by default. Long-lived commands then show up as
6
+ * "left-over process" entries every time the agent service restarts. Wrapping
7
+ * shell commands in a transient user scope keeps those user workloads alive
8
+ * without polluting the agent service lifecycle.
9
+ */
10
+
11
+ import { existsSync } from 'fs';
12
+ import { delimiter, isAbsolute, join } from 'path';
13
+
14
+ const DEFAULT_SCOPE_PREFIX = 'yeaft-shell';
15
+ const UNIT_MAX_LENGTH = 180;
16
+
17
+ function hasPathSeparator(command) {
18
+ return command.includes('/') || command.includes('\\');
19
+ }
20
+
21
+ export function findExecutableOnPath(command, env = process.env) {
22
+ if (!command || typeof command !== 'string') return null;
23
+ if (hasPathSeparator(command)) return existsSync(command) ? command : null;
24
+
25
+ const pathValue = env.PATH || '';
26
+ for (const dir of pathValue.split(delimiter)) {
27
+ if (!dir) continue;
28
+ const candidate = isAbsolute(dir) ? join(dir, command) : join(process.cwd(), dir, command);
29
+ if (existsSync(candidate)) return candidate;
30
+ }
31
+ return null;
32
+ }
33
+
34
+ export function shouldUseSystemdUserScope({ runtimePlatform, env = process.env, systemdRunPath = null } = {}) {
35
+ if (!runtimePlatform?.isLinux) return false;
36
+ if (env.YEAFT_DISABLE_SYSTEMD_SCOPE === '1') return false;
37
+
38
+ // INVOCATION_ID is set for systemd services and transient scopes. XDG_RUNTIME_DIR
39
+ // is required for `systemd-run --user` to talk to the user manager.
40
+ if (!env.INVOCATION_ID || !env.XDG_RUNTIME_DIR) return false;
41
+
42
+ const resolvedSystemdRun = systemdRunPath || findExecutableOnPath('systemd-run', env);
43
+ return !!resolvedSystemdRun;
44
+ }
45
+
46
+ export function sanitizeSystemdUnitPart(value) {
47
+ const raw = String(value || '').trim() || `${Date.now()}-${process.pid}`;
48
+ return raw
49
+ .replace(/[^A-Za-z0-9_.-]+/g, '-')
50
+ .replace(/^-+|-+$/g, '')
51
+ .slice(0, UNIT_MAX_LENGTH) || `${Date.now()}-${process.pid}`;
52
+ }
53
+
54
+ export function buildSystemdScopeName(scopeId, prefix = DEFAULT_SCOPE_PREFIX) {
55
+ const safePrefix = sanitizeSystemdUnitPart(prefix).slice(0, 48);
56
+ const safeId = sanitizeSystemdUnitPart(scopeId);
57
+ const base = `${safePrefix}-${safeId}`.slice(0, UNIT_MAX_LENGTH);
58
+ return base.endsWith('.scope') ? base : `${base}.scope`;
59
+ }
60
+
61
+ export function wrapInvocationInSystemdUserScope(invocation, {
62
+ runtimePlatform,
63
+ env = process.env,
64
+ scopeId = null,
65
+ scopePrefix = DEFAULT_SCOPE_PREFIX,
66
+ systemdRunPath = null,
67
+ } = {}) {
68
+ if (!shouldUseSystemdUserScope({ runtimePlatform, env, systemdRunPath })) {
69
+ return { ...invocation, systemdScope: null };
70
+ }
71
+
72
+ const scopeName = buildSystemdScopeName(scopeId, scopePrefix);
73
+ return {
74
+ command: systemdRunPath || findExecutableOnPath('systemd-run', env) || 'systemd-run',
75
+ args: [
76
+ '--user',
77
+ '--scope',
78
+ '--quiet',
79
+ '--collect',
80
+ `--unit=${scopeName}`,
81
+ invocation.command,
82
+ ...(invocation.args || []),
83
+ ],
84
+ family: invocation.family,
85
+ systemdScope: scopeName,
86
+ wrappedCommand: invocation.command,
87
+ };
88
+ }
@@ -11,6 +11,12 @@ import { startShellProcess } from './shell-runner.js';
11
11
  import { getRuntimePlatformInfo } from '../runtime-platform.js';
12
12
 
13
13
  const LOG_PREVIEW_BYTES = 4096;
14
+ const SUB_AGENT_LOG_PREVIEW_BYTES = 1024 * 1024;
15
+ const DEFAULT_CANCEL_ESCALATION_MS = 2000;
16
+
17
+ function logPreviewBytesFor(task) {
18
+ return task?.kind === 'sub_agent' ? SUB_AGENT_LOG_PREVIEW_BYTES : LOG_PREVIEW_BYTES;
19
+ }
14
20
 
15
21
  function nowIso() {
16
22
  return new Date().toISOString();
@@ -40,14 +46,23 @@ function publicSnapshot(task) {
40
46
  };
41
47
  }
42
48
 
49
+ function taskCommand(task) {
50
+ const command = task?.runtime?.command;
51
+ return typeof command === 'string' && command.trim() ? command.trim() : '';
52
+ }
53
+
43
54
  export class TaskManager {
44
- constructor({ yeaftDir, onEvent = null, runtimePlatform = null } = {}) {
55
+ constructor({ yeaftDir, onEvent = null, runtimePlatform = null, cancelEscalationMs = DEFAULT_CANCEL_ESCALATION_MS } = {}) {
45
56
  if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
46
57
  this.store = new TaskStore({ yeaftDir });
47
58
  this.onEvent = typeof onEvent === 'function' ? onEvent : null;
48
59
  this.runtimePlatform = runtimePlatform || getRuntimePlatformInfo();
60
+ this.cancelEscalationMs = Number.isFinite(cancelEscalationMs)
61
+ ? Math.max(0, Math.floor(cancelEscalationMs))
62
+ : DEFAULT_CANCEL_ESCALATION_MS;
49
63
  this.active = new Map();
50
64
  this.processes = new Map();
65
+ this.cancelEscalationTimers = new Map();
51
66
  this.#loadPersistedRunningTasks();
52
67
  }
53
68
 
@@ -134,6 +149,7 @@ export class TaskManager {
134
149
  command,
135
150
  cwd,
136
151
  pid: null,
152
+ systemdScope: null,
137
153
  platform: (runtimePlatform || this.runtimePlatform)?.platform || process.platform,
138
154
  },
139
155
  log: {
@@ -155,6 +171,7 @@ export class TaskManager {
155
171
  command,
156
172
  cwd,
157
173
  runtimePlatform: runtime,
174
+ scopeId: task.id,
158
175
  onOutput: (stream, text) => {
159
176
  const prefix = stream === 'stderr' ? '[stderr] ' : '';
160
177
  this.store.appendLog(task.sessionId, task.id, prefix ? text.split(/(\n)/).map(part => part === '\n' ? part : (part ? `${prefix}${part}` : part)).join('') : text);
@@ -176,6 +193,7 @@ export class TaskManager {
176
193
  });
177
194
 
178
195
  task.runtime.pid = runner.pid;
196
+ task.runtime.systemdScope = runner.systemdScope || null;
179
197
  this.processes.set(this.#key(task.sessionId, task.id), runner);
180
198
  this.store.writeTask(task);
181
199
  this.#emit('updated', task);
@@ -186,9 +204,15 @@ export class TaskManager {
186
204
  const key = this.#key(sessionId, taskId);
187
205
  const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
188
206
  if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
207
+ const escalationTimer = this.cancelEscalationTimers.get(key);
208
+ if (escalationTimer) {
209
+ clearTimeout(escalationTimer);
210
+ this.cancelEscalationTimers.delete(key);
211
+ }
189
212
  const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
190
- const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
191
- task.status = status || TASK_STATUS.FAILED;
213
+ const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: logPreviewBytesFor(task) });
214
+ const cancelRequested = !!task.runtime?.cancelRequestedAt;
215
+ task.status = cancelRequested ? TASK_STATUS.CANCELLED : (status || TASK_STATUS.FAILED);
192
216
  task.updatedAt = nowIso();
193
217
  task.endedAt = nowIso();
194
218
  task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
@@ -207,19 +231,61 @@ export class TaskManager {
207
231
  if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
208
232
  if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
209
233
  const runner = this.processes.get(key);
210
- const killed = runner ? runner.kill('SIGTERM') : false;
211
- if (!killed) {
234
+ if (!runner) {
212
235
  return {
213
236
  ok: false,
214
- error: 'Unable to cancel task: no live process handle or process-tree kill failed.',
237
+ error: 'Unable to cancel task: no live process handle.',
215
238
  task: publicSnapshot(task),
216
239
  };
217
240
  }
218
- const completed = this.#completeTask(sessionId, taskId, {
219
- status: TASK_STATUS.CANCELLED,
220
- signal: 'SIGTERM',
221
- });
222
- return { ok: true, task: completed };
241
+
242
+ if (!task.runtime?.cancelRequestedAt) {
243
+ const signalled = runner.kill('SIGTERM');
244
+ if (!signalled) {
245
+ return {
246
+ ok: false,
247
+ error: 'Unable to cancel task: process-tree signal failed.',
248
+ task: publicSnapshot(task),
249
+ };
250
+ }
251
+ const cancelRequestedAt = nowIso();
252
+ task.runtime = {
253
+ ...(task.runtime || {}),
254
+ cancelRequestedAt,
255
+ cancelSignal: 'SIGTERM',
256
+ cancelEscalationMs: this.cancelEscalationMs,
257
+ };
258
+ task.updatedAt = cancelRequestedAt;
259
+ this.store.writeTask(task);
260
+ this.active.set(key, task);
261
+ this.store.appendEvent(sessionId, { event: 'cancel_requested', taskId, signal: 'SIGTERM' });
262
+ this.#emit('updated', task, { cancelRequested: true });
263
+
264
+ if (this.cancelEscalationMs >= 0 && !this.cancelEscalationTimers.has(key)) {
265
+ const timer = setTimeout(() => {
266
+ this.cancelEscalationTimers.delete(key);
267
+ const current = this.active.get(key) || this.store.readTask(sessionId, taskId);
268
+ if (!current || isTerminalTaskStatus(current.status)) return;
269
+ const liveRunner = this.processes.get(key);
270
+ const escalated = liveRunner ? liveRunner.kill('SIGKILL') : false;
271
+ current.runtime = {
272
+ ...(current.runtime || {}),
273
+ cancelEscalatedAt: nowIso(),
274
+ cancelEscalatedSignal: 'SIGKILL',
275
+ cancelEscalationFailed: !escalated,
276
+ };
277
+ current.updatedAt = current.runtime.cancelEscalatedAt;
278
+ this.store.writeTask(current);
279
+ this.active.set(key, current);
280
+ this.store.appendEvent(sessionId, { event: 'cancel_escalated', taskId, signal: 'SIGKILL', ok: escalated });
281
+ this.#emit('updated', current, { cancelEscalated: true, cancelEscalationOk: escalated });
282
+ }, this.cancelEscalationMs);
283
+ if (typeof timer.unref === 'function') timer.unref();
284
+ this.cancelEscalationTimers.set(key, timer);
285
+ }
286
+ }
287
+
288
+ return { ok: true, task: publicSnapshot(task), pending: true };
223
289
  }
224
290
 
225
291
  listActiveTasks(sessionId = null) {
@@ -254,7 +320,7 @@ export class TaskManager {
254
320
  const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
255
321
  if (!task) return null;
256
322
  const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
257
- const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
323
+ const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: logPreviewBytesFor(task) });
258
324
  task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
259
325
  task.updatedAt = nowIso();
260
326
  this.store.writeTask(task);
@@ -269,7 +335,9 @@ export class TaskManager {
269
335
  const lines = ['<active_tasks>'];
270
336
  for (const task of tasks) {
271
337
  const preview = (task.log?.preview || '').trim().split('\n').slice(-3).join(' | ');
272
- lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
338
+ const command = taskCommand(task);
339
+ const cancelRequestedAt = typeof task.runtime?.cancelRequestedAt === 'string' ? task.runtime.cancelRequestedAt : '';
340
+ lines.push(`- ${task.id} | ${task.kind} | ${task.status} | owner=${task.ownerVpId || 'unknown'} | title=${JSON.stringify(task.title)}${command ? ` | command=${JSON.stringify(command)}` : ''}${cancelRequestedAt ? ` | cancelRequestedAt=${JSON.stringify(cancelRequestedAt)}` : ''} | log=${task.log?.path || ''}${preview ? ` | tail=${JSON.stringify(preview)}` : ''}`);
273
341
  }
274
342
  lines.push('</active_tasks>');
275
343
  return lines.join('\n');
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { spawn, spawnSync } from 'child_process';
6
6
  import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
7
+ import { wrapInvocationInSystemdUserScope } from '../systemd-scope.js';
7
8
 
8
9
  export function buildWindowsTaskkillArgs(pid) {
9
10
  return ['/pid', String(pid), '/t', '/f'];
@@ -33,12 +34,18 @@ export function killShellProcessTree(pid, runtimePlatform, signal = 'SIGTERM') {
33
34
  }
34
35
  }
35
36
 
36
- export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onExit, onError }) {
37
+ export function startShellProcess({ command, cwd, runtimePlatform, scopeId = null, onOutput, onExit, onError }) {
37
38
  const platform = runtimePlatform || getRuntimePlatformInfo();
38
- const invocation = buildShellInvocation(command, { runtimePlatform: platform });
39
+ const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
40
+ const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
41
+ const invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
42
+ runtimePlatform: platform,
43
+ env,
44
+ scopeId,
45
+ });
39
46
  const proc = spawn(invocation.command, invocation.args, {
40
47
  cwd,
41
- env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
48
+ env,
42
49
  stdio: ['ignore', 'pipe', 'pipe'],
43
50
  detached: !platform.isWindows,
44
51
  windowsHide: true,
@@ -57,6 +64,7 @@ export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onE
57
64
 
58
65
  return {
59
66
  pid: proc.pid || null,
67
+ systemdScope: invocation.systemdScope || null,
60
68
  kill(signal = 'SIGTERM') {
61
69
  return killShellProcessTree(proc.pid, platform, signal);
62
70
  },
@@ -13,6 +13,7 @@ import { spawn } from 'child_process';
13
13
  import { existsSync } from 'fs';
14
14
  import { resolve } from 'path';
15
15
  import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
16
+ import { wrapInvocationInSystemdUserScope } from '../systemd-scope.js';
16
17
 
17
18
  export { buildShellInvocation };
18
19
 
@@ -32,10 +33,16 @@ const MAX_TIMEOUT_MS = 600_000;
32
33
  function runCommand(command, { cwd, timeout, signal, runtimePlatform }) {
33
34
  return new Promise((resolve) => {
34
35
  const platform = runtimePlatform || getRuntimePlatformInfo();
35
- const invocation = buildShellInvocation(command, { runtimePlatform: platform });
36
+ const env = { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' };
37
+ const baseInvocation = buildShellInvocation(command, { runtimePlatform: platform });
38
+ const invocation = wrapInvocationInSystemdUserScope(baseInvocation, {
39
+ runtimePlatform: platform,
40
+ env,
41
+ scopeId: `foreground-${Date.now()}-${process.pid}`,
42
+ });
36
43
  const proc = spawn(invocation.command, invocation.args, {
37
44
  cwd,
38
- env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
45
+ env,
39
46
  stdio: ['ignore', 'pipe', 'pipe'],
40
47
  detached: !platform.isWindows,
41
48
  });
@@ -17,6 +17,11 @@ export const VALID_STATES = new Set([
17
17
  ]);
18
18
 
19
19
  const RUNNING_STATES = new Set(['typing', 'thinking', 'streaming', 'tool']);
20
+
21
+ export function isVpStatusRunning(state) {
22
+ return RUNNING_STATES.has(state || 'idle');
23
+ }
24
+
20
25
  const STATE_PRIORITY = ['tool', 'streaming', 'thinking', 'typing', 'error', 'idle'];
21
26
  const MAX_RETAINED_THREADS_PER_VP = 20;
22
27
  const COMPLETED_TTL_MS = 30 * 60 * 1000;
@@ -52,7 +57,7 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
52
57
  }
53
58
  }
54
59
 
55
- const runningThreadCount = rows.filter(r => RUNNING_STATES.has(r.state)).length;
60
+ const runningThreadCount = rows.filter(r => isVpStatusRunning(r.state)).length;
56
61
  const latest = rows[0] || null;
57
62
  return {
58
63
  sessionId: sessionId || null,
@@ -83,14 +88,14 @@ export function createVpStatusBroker({ send, now = Date.now } = {}) {
83
88
  }
84
89
  const cutoff = now() - COMPLETED_TTL_MS;
85
90
  for (const [key, row] of rows) {
86
- if (RUNNING_STATES.has(row.state)) continue;
91
+ if (isVpStatusRunning(row.state)) continue;
87
92
  if ((row.updatedAt || row.since || 0) < cutoff) threads.delete(key);
88
93
  }
89
94
  const remaining = rows
90
95
  .filter(([key]) => threads.has(key))
91
96
  .sort((a, b) => (b[1].updatedAt || b[1].since || 0) - (a[1].updatedAt || a[1].since || 0));
92
97
  for (const [key, row] of remaining.slice(MAX_RETAINED_THREADS_PER_VP)) {
93
- if (!RUNNING_STATES.has(row.state)) threads.delete(key);
98
+ if (!isVpStatusRunning(row.state)) threads.delete(key);
94
99
  }
95
100
  }
96
101