@yeaft/webchat-agent 0.1.1065 → 0.1.1067

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@yeaft/webchat-agent",
3
- "version": "0.1.1065",
3
+ "version": "0.1.1067",
4
4
  "description": "Remote agent for Yeaft WebChat — connects worker machines to the central server",
5
5
  "main": "index.js",
6
6
  "type": "module",
@@ -186,6 +186,9 @@ function serializeMessage(msg) {
186
186
  if (msg.model) fm.push(`model: ${msg.model}`);
187
187
  if (msg.turnNumber != null) fm.push(`turnNumber: ${msg.turnNumber}`);
188
188
  if (msg.toolCallId) fm.push(`toolCallId: ${msg.toolCallId}`);
189
+ if (msg.eventType) fm.push(`eventType: ${msg.eventType}`);
190
+ if (msg.taskId) fm.push(`taskId: ${msg.taskId}`);
191
+ if (msg.taskStatus) fm.push(`taskStatus: ${msg.taskStatus}`);
189
192
  if (msg.isError) fm.push(`isError: true`);
190
193
  // task-307: every message is stamped with a threadId so multi-thread
191
194
  // routing can filter/replay by thread without rescanning JSON blobs.
@@ -312,6 +315,9 @@ export function parseMessage(raw) {
312
315
  case 'model': msg.model = value; break;
313
316
  case 'turnNumber': msg.turnNumber = parseInt(value, 10); break;
314
317
  case 'toolCallId': msg.toolCallId = value; break;
318
+ case 'eventType': msg.eventType = value; break;
319
+ case 'taskId': msg.taskId = value; break;
320
+ case 'taskStatus': msg.taskStatus = value; break;
315
321
  case 'isError': msg.isError = value === 'true'; break;
316
322
  case 'tokens_est': msg.tokens_est = parseInt(value, 10); break;
317
323
  case 'threadId': msg.threadId = value; break;
package/yeaft/engine.js CHANGED
@@ -387,6 +387,9 @@ export class Engine {
387
387
  /** @type {import('./tools/registry.js').ToolRegistry|null} */
388
388
  #toolRegistry;
389
389
 
390
+ /** @type {import('./tasks/manager.js').TaskManager|null} */
391
+ #taskManager;
392
+
390
393
  /** @type {import('./skills.js').SkillManager|null} */
391
394
  #skillManager;
392
395
 
@@ -501,7 +504,7 @@ export class Engine {
501
504
  * toolStats?: import('./stats/tool-usage.js').ToolUsageStats,
502
505
  * }} params
503
506
  */
504
- constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, sessionId = null, vpId = null, chatId = null }) {
507
+ constructor({ adapter, trace, config, conversationStore, memoryIndex, amsRegistry, toolRegistry, skillManager, mcpManager, yeaftDir, toolStats = null, taskManager = null, sessionId = null, vpId = null, chatId = null }) {
505
508
  this.#adapter = adapter;
506
509
  this.#trace = trace;
507
510
  this.#config = config;
@@ -511,6 +514,7 @@ export class Engine {
511
514
  this.#memoryIndex = memoryIndex || null;
512
515
  this.#amsRegistry = amsRegistry || null;
513
516
  this.#toolRegistry = toolRegistry || null;
517
+ this.#taskManager = taskManager || null;
514
518
  this.#skillManager = skillManager || null;
515
519
  this.#mcpManager = mcpManager || null;
516
520
  this.#yeaftDir = yeaftDir || null;
@@ -870,7 +874,7 @@ export class Engine {
870
874
  * @param {object} [args.taskCtx] — legacy task-context sub-block (optional)
871
875
  * @returns {string}
872
876
  */
873
- #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx } = {}) {
877
+ #buildSystemPrompt({ prompt, memoryInjection, vpPersona, activeScope, sessionAnnouncement, projectDoc, taskCtx, activeTasks } = {}) {
874
878
  // Get relevant skill content if SkillManager is wired
875
879
  let skillContent = '';
876
880
  if (this.#skillManager && prompt) {
@@ -893,6 +897,7 @@ export class Engine {
893
897
  projectDoc,
894
898
  runtimePlatform: getRuntimePlatformInfo(),
895
899
  taskCtx,
900
+ activeTasks,
896
901
  // Worker-shape harness is descriptive metadata for human inspection;
897
902
  // production prompts skip it to save tokens. Re-enable via env when
898
903
  // diagnosing prompt structure issues.
@@ -997,6 +1002,10 @@ export class Engine {
997
1002
  conversationStore: this.#conversationStore,
998
1003
  adapter: this.#adapter,
999
1004
  config: this.#config,
1005
+ taskManager: this.#taskManager,
1006
+ sessionId: vpCtx?.sessionId || this.#sessionId || null,
1007
+ threadId: vpCtx?.threadId || this.#currentThreadId || MAIN_THREAD_ID,
1008
+ currentVpId: vpCtx?.senderVpId || this.#vpId || null,
1000
1009
  // task-704b: per-tool-result hard cap derives from this. Threaded
1001
1010
  // from the live model (resolveModel(currentModel)) every turn so
1002
1011
  // fallbackModel switches see the new window. Falls back to
@@ -1047,6 +1056,7 @@ export class Engine {
1047
1056
  parentVpId: vpCtx?.senderVpId || null,
1048
1057
  parentVpPersona: vpCtx?.vpPersona || null,
1049
1058
  parentSessionId: vpCtx?.sessionId || null,
1059
+ parentThreadId: vpCtx?.threadId || this.#currentThreadId || MAIN_THREAD_ID,
1050
1060
  onEvent: this.#subAgentEventSink || null,
1051
1061
  language: this.#config?.language || 'en',
1052
1062
  // Forward the session-shared ToolUsageStats so sub-agent
@@ -1054,6 +1064,7 @@ export class Engine {
1054
1064
  // (~/.yeaft/stats/tool-usage.json) the parent engine writes
1055
1065
  // to. Null when the parent has no stats wired (e.g. tests).
1056
1066
  toolStats: this.#toolStats || null,
1067
+ taskManager: this.#taskManager || null,
1057
1068
  },
1058
1069
  };
1059
1070
  }
@@ -1614,6 +1625,9 @@ export class Engine {
1614
1625
  };
1615
1626
 
1616
1627
  const projectDoc = this.#getProjectDocBlock(workDir);
1628
+ const activeTasks = this.#taskManager
1629
+ ? this.#taskManager.renderActiveTasksForPrompt(runtimeSessionId)
1630
+ : '';
1617
1631
 
1618
1632
  const systemPrompt = this.#buildSystemPrompt({
1619
1633
  prompt,
@@ -1622,6 +1636,7 @@ export class Engine {
1622
1636
  activeScope,
1623
1637
  sessionAnnouncement,
1624
1638
  projectDoc,
1639
+ activeTasks,
1625
1640
  });
1626
1641
 
1627
1642
  // ─── HARD INVARIANT: Compact ≠ Dream (read DESIGN-COMPACT-VS-DREAM.md) ─
package/yeaft/prompts.js CHANGED
@@ -309,6 +309,7 @@ export function buildSystemPrompt({
309
309
  sessionAnnouncement = '',
310
310
  projectDoc = '',
311
311
  runtimePlatform,
312
+ activeTasks = '',
312
313
  } = {}) {
313
314
  // Normalize app locales like `zh-CN` to prompt dictionary/template keys.
314
315
  const effectiveLang = normalizePromptLanguage(language);
@@ -411,6 +412,9 @@ export function buildSystemPrompt({
411
412
  const activeScopeBlock = renderActiveScope(activeScope, lang);
412
413
  if (activeScopeBlock) parts.push(activeScopeBlock);
413
414
 
415
+ const activeTaskText = typeof activeTasks === 'string' ? activeTasks.trim() : '';
416
+ if (activeTaskText) parts.push(activeTaskText);
417
+
414
418
  const multiVpRoutingBlock = renderMultiVpRouting(activeScope, lang);
415
419
  if (multiVpRoutingBlock) parts.push(multiVpRoutingBlock);
416
420
 
@@ -51,6 +51,35 @@ export function resolveDefaultShell(opts = {}) {
51
51
  };
52
52
  }
53
53
 
54
+ /**
55
+ * Build a platform-specific shell invocation for executing a command string.
56
+ * Kept in runtime-platform so tools and task runners share OS behavior without
57
+ * depending on each other.
58
+ *
59
+ * @param {string} command
60
+ * @param {{ runtimePlatform?: object }} opts
61
+ */
62
+ export function buildShellInvocation(command, opts = {}) {
63
+ const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
64
+ const shell = runtimePlatform.defaultShell
65
+ ? {
66
+ command: runtimePlatform.defaultShell,
67
+ argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
68
+ family: runtimePlatform.shellFamily,
69
+ }
70
+ : resolveDefaultShell({ platform: runtimePlatform.platform });
71
+
72
+ const argsPrefix = Array.isArray(shell.argsPrefix)
73
+ ? shell.argsPrefix
74
+ : resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
75
+
76
+ return {
77
+ command: shell.command,
78
+ args: [...argsPrefix, command],
79
+ family: shell.family || runtimePlatform.shellFamily || 'posix',
80
+ };
81
+ }
82
+
54
83
  /**
55
84
  * @param {{ platform?: string, env?: NodeJS.ProcessEnv }} [opts]
56
85
  */
package/yeaft/session.js CHANGED
@@ -26,6 +26,7 @@ import { Engine } from './engine.js';
26
26
  import { Compactor } from './compact/compactor.js';
27
27
  import { resolveContextWindow } from './models.js';
28
28
  import { ToolUsageStats } from './stats/tool-usage.js';
29
+ import { TaskManager } from './tasks/manager.js';
29
30
  // H2.f.5 removed the old user-facing thread pipeline/dispatcher. The base
30
31
  // session still exposes a single default Engine; PR #797 adds group VP thread
31
32
  // engines in web-bridge runtime state, keyed below the session layer.
@@ -399,6 +400,7 @@ export async function loadSession(options = {}) {
399
400
  }
400
401
 
401
402
  // ─── 8. Build tool registry ────────────────────────────
403
+ const taskManager = new TaskManager({ yeaftDir, conversationStore });
402
404
  const toolRegistry = createFullRegistry();
403
405
 
404
406
  // Register any extra tools from caller
@@ -456,6 +458,7 @@ export async function loadSession(options = {}) {
456
458
  mcpManager,
457
459
  yeaftDir,
458
460
  toolStats,
461
+ taskManager,
459
462
  });
460
463
 
461
464
  // ─── 9a-pre. Create per-group history Compactor ────────
@@ -608,6 +611,7 @@ export async function loadSession(options = {}) {
608
611
  status,
609
612
  amsRegistry,
610
613
  toolStats,
614
+ taskManager,
611
615
  shutdown,
612
616
  // task-325c: user-initiated abort API. Delegates to web-bridge which
613
617
  // owns the single AbortController. Lazy-imported to avoid a hard cycle
@@ -154,6 +154,7 @@ export function startSubAgent(agent, deps = {}) {
154
154
  mcpManager: deps.mcpManager || null,
155
155
  yeaftDir: deps.yeaftDir || null,
156
156
  toolStats: deps.toolStats || null,
157
+ taskManager: deps.taskManager || null,
157
158
  });
158
159
 
159
160
  agent.subEngine = subEngine;
@@ -164,6 +165,9 @@ export function startSubAgent(agent, deps = {}) {
164
165
  agent.parentThreadId = deps.parentThreadId || 'main';
165
166
  agent.outputLog = createOutputLog(agent.id, deps.subAgentLogDir);
166
167
  agent.outputFile = agent.outputLog.path;
168
+ if (agent.taskId && deps.taskManager && agent.parentSessionId) {
169
+ try { deps.taskManager.setTaskLogPath(agent.parentSessionId, agent.taskId, agent.outputFile); } catch { /* ignore */ }
170
+ }
167
171
  agent.outputLog.write({ type: 'sub_agent_spawned', agentId: agent.id, agentName: agent.name, mission: agent.mission || agent.task || '' });
168
172
 
169
173
  // Compose the system-prompt-overlay we want injected.
@@ -268,6 +272,9 @@ async function driveSubAgent(agent, subEngine, vpPersona, deps) {
268
272
  const emit = (evt) => {
269
273
  const wrapped = wrapEvt(evt);
270
274
  try { agent.outputLog?.write(wrapped); } catch { /* ignore log failures */ }
275
+ if (agent.taskId && deps.taskManager && agent.parentSessionId) {
276
+ try { deps.taskManager.refreshTaskLog(agent.parentSessionId, agent.taskId); } catch { /* ignore */ }
277
+ }
271
278
  if (onEvent) {
272
279
  try { onEvent(agent.id, wrapped); } catch { /* ignore listener errors */ }
273
280
  }
@@ -497,6 +504,17 @@ function finalizeTerminal(agent, status, { error, deps } = {}) {
497
504
  error: error || agent.error || null,
498
505
  };
499
506
  try { agent.outputLog?.write(evt); } catch { /* ignore */ }
507
+ if (agent.taskId && deps?.taskManager && agent.parentSessionId) {
508
+ const taskStatus = status === STATUS.COMPLETED ? 'succeeded'
509
+ : status === STATUS.CLOSED ? 'cancelled'
510
+ : 'failed';
511
+ try {
512
+ deps.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
513
+ status: taskStatus,
514
+ error: error || agent.error || null,
515
+ });
516
+ } catch { /* ignore */ }
517
+ }
500
518
  if (deps && typeof deps.onEvent === 'function') {
501
519
  try { deps.onEvent(agent.id, evt); } catch { /* ignore */ }
502
520
  }
@@ -0,0 +1,327 @@
1
+ /**
2
+ * manager.js — Session-scoped background task manager.
3
+ *
4
+ * First-class tasks cover shell background commands today and provide the
5
+ * shared model that sub-agents can attach to next.
6
+ */
7
+
8
+ import { randomUUID } from 'crypto';
9
+ import { TaskStore, TASK_STATUS, isTerminalTaskStatus } from './store.js';
10
+ import { startShellProcess } from './shell-runner.js';
11
+ import { getRuntimePlatformInfo } from '../runtime-platform.js';
12
+
13
+ const LOG_PREVIEW_BYTES = 4096;
14
+
15
+ function nowIso() {
16
+ return new Date().toISOString();
17
+ }
18
+
19
+ function makeTaskId() {
20
+ return `task_${Date.now().toString(36)}_${randomUUID().slice(0, 8)}`;
21
+ }
22
+
23
+ function publicSnapshot(task) {
24
+ if (!task) return null;
25
+ return {
26
+ id: task.id,
27
+ sessionId: task.sessionId,
28
+ ownerVpId: task.ownerVpId || null,
29
+ kind: task.kind,
30
+ title: task.title,
31
+ status: task.status,
32
+ createdAt: task.createdAt,
33
+ startedAt: task.startedAt,
34
+ updatedAt: task.updatedAt,
35
+ endedAt: task.endedAt || null,
36
+ runtime: task.runtime || {},
37
+ log: task.log || {},
38
+ result: task.result || {},
39
+ source: task.source || {},
40
+ };
41
+ }
42
+
43
+ function formatTaskMessage(kind, task, extra = {}) {
44
+ const lines = [];
45
+ const label = kind === 'started' ? 'Task started' : 'Task finished';
46
+ lines.push(`[${label}]`);
47
+ lines.push(`taskId: ${task.id}`);
48
+ lines.push(`kind: ${task.kind}`);
49
+ lines.push(`status: ${task.status}`);
50
+ lines.push(`ownerVpId: ${task.ownerVpId || 'unknown'}`);
51
+ lines.push(`title: ${task.title}`);
52
+ if (task.runtime?.command) lines.push(`command: ${task.runtime.command}`);
53
+ if (task.runtime?.cwd) lines.push(`cwd: ${task.runtime.cwd}`);
54
+ if (task.log?.path) lines.push(`log: ${task.log.path}`);
55
+ if (extra.exitCode != null) lines.push(`exitCode: ${extra.exitCode}`);
56
+ if (extra.signal) lines.push(`signal: ${extra.signal}`);
57
+ if (extra.summary) lines.push(`summary: ${extra.summary}`);
58
+ if (extra.logTail) {
59
+ lines.push('logTail:');
60
+ lines.push(extra.logTail.trimEnd());
61
+ }
62
+ return lines.join('\n');
63
+ }
64
+
65
+ export class TaskManager {
66
+ constructor({ yeaftDir, conversationStore = null, onEvent = null, runtimePlatform = null } = {}) {
67
+ if (!yeaftDir) throw new Error('TaskManager requires yeaftDir');
68
+ this.store = new TaskStore({ yeaftDir });
69
+ this.conversationStore = conversationStore;
70
+ this.onEvent = typeof onEvent === 'function' ? onEvent : null;
71
+ this.runtimePlatform = runtimePlatform || getRuntimePlatformInfo();
72
+ this.active = new Map();
73
+ this.processes = new Map();
74
+ this.#loadPersistedRunningTasks();
75
+ }
76
+
77
+ setEventSink(onEvent) {
78
+ this.onEvent = typeof onEvent === 'function' ? onEvent : null;
79
+ }
80
+
81
+ #key(sessionId, taskId) {
82
+ return `${sessionId || 'default'}::${taskId}`;
83
+ }
84
+
85
+ #emit(event, task, extra = {}) {
86
+ const payload = { type: 'yeaft_task_event', event, task: publicSnapshot(task), ...extra };
87
+ try { this.onEvent?.(payload); } catch { /* event sinks must not break tasks */ }
88
+ }
89
+
90
+ #persistLifecycleMessage(task, kind, extra = {}) {
91
+ if (!this.conversationStore || !task?.sessionId) return;
92
+ try {
93
+ this.conversationStore.append({
94
+ role: 'assistant',
95
+ content: formatTaskMessage(kind, task, extra),
96
+ sessionId: task.sessionId,
97
+ threadId: task.source?.threadId || 'main',
98
+ ...(task.ownerVpId ? { speakerVpId: task.ownerVpId } : {}),
99
+ eventType: 'task_lifecycle',
100
+ taskId: task.id,
101
+ taskStatus: task.status,
102
+ });
103
+ } catch {
104
+ // Conversation persistence must not kill the background process.
105
+ }
106
+ }
107
+
108
+ #loadPersistedRunningTasks() {
109
+ for (const task of this.store.loadActiveTasks()) {
110
+ const orphaned = {
111
+ ...task,
112
+ status: TASK_STATUS.ORPHANED,
113
+ updatedAt: nowIso(),
114
+ endedAt: nowIso(),
115
+ result: {
116
+ ...(task.result || {}),
117
+ error: 'Agent restarted while task was running; process control was lost.',
118
+ },
119
+ };
120
+ this.store.writeTask(orphaned);
121
+ this.store.appendEvent(orphaned.sessionId, { event: 'orphaned', taskId: orphaned.id });
122
+ this.#persistLifecycleMessage(orphaned, 'finished', { summary: orphaned.result.error });
123
+ this.#emit('completed', orphaned);
124
+ }
125
+ }
126
+
127
+ startTask({ sessionId, ownerVpId = null, kind = 'tool', title = '', runtime = {}, source = {}, logPath = null } = {}) {
128
+ const taskId = makeTaskId();
129
+ const resolvedSessionId = sessionId || 'default';
130
+ const task = {
131
+ id: taskId,
132
+ sessionId: resolvedSessionId,
133
+ ownerVpId,
134
+ kind,
135
+ title: title || kind,
136
+ status: TASK_STATUS.RUNNING,
137
+ createdAt: nowIso(),
138
+ startedAt: nowIso(),
139
+ updatedAt: nowIso(),
140
+ endedAt: null,
141
+ source,
142
+ runtime,
143
+ log: {
144
+ path: logPath || this.store.logPath(resolvedSessionId, taskId),
145
+ bytes: 0,
146
+ preview: '',
147
+ },
148
+ result: {},
149
+ };
150
+ this.store.writeTask(task);
151
+ this.store.appendEvent(task.sessionId, { event: 'started', taskId: task.id, kind: task.kind });
152
+ this.active.set(this.#key(task.sessionId, task.id), task);
153
+ this.#persistLifecycleMessage(task, 'started');
154
+ this.#emit('started', task);
155
+ return publicSnapshot(task);
156
+ }
157
+
158
+ completeTask(sessionId, taskId, opts = {}) {
159
+ return this.#completeTask(sessionId, taskId, opts);
160
+ }
161
+
162
+ startShellTask({ command, cwd, sessionId, ownerVpId = null, title = '', source = {}, runtimePlatform = null } = {}) {
163
+ if (!command || typeof command !== 'string') throw new Error('command is required');
164
+ const task = {
165
+ id: makeTaskId(),
166
+ sessionId: sessionId || 'default',
167
+ ownerVpId,
168
+ kind: 'shell',
169
+ title: title || command.slice(0, 120),
170
+ status: TASK_STATUS.RUNNING,
171
+ createdAt: nowIso(),
172
+ startedAt: nowIso(),
173
+ updatedAt: nowIso(),
174
+ endedAt: null,
175
+ source,
176
+ runtime: {
177
+ command,
178
+ cwd,
179
+ pid: null,
180
+ platform: (runtimePlatform || this.runtimePlatform)?.platform || process.platform,
181
+ },
182
+ log: {
183
+ path: this.store.logPath(sessionId || 'default', 'pending'),
184
+ bytes: 0,
185
+ preview: '',
186
+ },
187
+ result: {},
188
+ };
189
+ task.log.path = this.store.logPath(task.sessionId, task.id);
190
+
191
+ this.store.writeTask(task);
192
+ this.store.appendEvent(task.sessionId, { event: 'started', taskId: task.id, kind: task.kind });
193
+ this.active.set(this.#key(task.sessionId, task.id), task);
194
+ this.#persistLifecycleMessage(task, 'started');
195
+ this.#emit('started', task);
196
+
197
+ const runtime = runtimePlatform || this.runtimePlatform;
198
+ const runner = startShellProcess({
199
+ command,
200
+ cwd,
201
+ runtimePlatform: runtime,
202
+ onOutput: (stream, text) => {
203
+ const prefix = stream === 'stderr' ? '[stderr] ' : '';
204
+ this.store.appendLog(task.sessionId, task.id, prefix ? text.split(/(\n)/).map(part => part === '\n' ? part : (part ? `${prefix}${part}` : part)).join('') : text);
205
+ this.refreshTaskLog(task.sessionId, task.id);
206
+ },
207
+ onExit: ({ code, signal }) => {
208
+ this.#completeTask(task.sessionId, task.id, {
209
+ status: code === 0 ? TASK_STATUS.SUCCEEDED : TASK_STATUS.FAILED,
210
+ exitCode: code,
211
+ signal,
212
+ });
213
+ },
214
+ onError: (err) => {
215
+ this.#completeTask(task.sessionId, task.id, {
216
+ status: TASK_STATUS.FAILED,
217
+ error: err?.message || String(err),
218
+ });
219
+ },
220
+ });
221
+
222
+ task.runtime.pid = runner.pid;
223
+ this.processes.set(this.#key(task.sessionId, task.id), runner);
224
+ this.store.writeTask(task);
225
+ this.#emit('updated', task);
226
+ return publicSnapshot(task);
227
+ }
228
+
229
+ #completeTask(sessionId, taskId, { status, exitCode = null, signal = null, error = null } = {}) {
230
+ const key = this.#key(sessionId, taskId);
231
+ const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
232
+ if (!task || isTerminalTaskStatus(task.status)) return publicSnapshot(task);
233
+ const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
234
+ const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
235
+ task.status = status || TASK_STATUS.FAILED;
236
+ task.updatedAt = nowIso();
237
+ task.endedAt = nowIso();
238
+ task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
239
+ task.result = { ...(task.result || {}), exitCode, signal, error };
240
+ this.store.writeTask(task);
241
+ this.store.appendEvent(sessionId, { event: 'completed', taskId, status: task.status, exitCode, signal, error });
242
+ this.active.delete(key);
243
+ this.processes.delete(key);
244
+ this.#persistLifecycleMessage(task, 'finished', {
245
+ exitCode,
246
+ signal,
247
+ summary: error || `Task ${task.status}`,
248
+ logTail: tail.text,
249
+ });
250
+ this.#emit('completed', task);
251
+ return publicSnapshot(task);
252
+ }
253
+
254
+ cancelTask(sessionId, taskId) {
255
+ const key = this.#key(sessionId, taskId);
256
+ const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
257
+ if (!task) return { ok: false, error: `Unknown task: ${taskId}` };
258
+ if (isTerminalTaskStatus(task.status)) return { ok: true, task: publicSnapshot(task) };
259
+ const runner = this.processes.get(key);
260
+ const killed = runner ? runner.kill('SIGTERM') : false;
261
+ if (!killed) {
262
+ return {
263
+ ok: false,
264
+ error: 'Unable to cancel task: no live process handle or process-tree kill failed.',
265
+ task: publicSnapshot(task),
266
+ };
267
+ }
268
+ const completed = this.#completeTask(sessionId, taskId, {
269
+ status: TASK_STATUS.CANCELLED,
270
+ signal: 'SIGTERM',
271
+ });
272
+ return { ok: true, task: completed };
273
+ }
274
+
275
+ listActiveTasks(sessionId = null) {
276
+ const tasks = Array.from(this.active.values()).filter(task => !sessionId || task.sessionId === sessionId);
277
+ return tasks.map(publicSnapshot);
278
+ }
279
+
280
+ getTask(sessionId, taskId) {
281
+ return publicSnapshot(this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId));
282
+ }
283
+
284
+ readTaskLog(sessionId, taskId, opts = {}) {
285
+ const task = this.active.get(this.#key(sessionId, taskId)) || this.store.readTask(sessionId, taskId);
286
+ if (task?.log?.path) return this.store.readLogFile(task.log.path, opts);
287
+ return this.store.readLog(sessionId, taskId, opts);
288
+ }
289
+
290
+ setTaskLogPath(sessionId, taskId, logPath) {
291
+ if (!logPath || typeof logPath !== 'string') return null;
292
+ const key = this.#key(sessionId, taskId);
293
+ const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
294
+ if (!task) return null;
295
+ task.log = { ...(task.log || {}), path: logPath };
296
+ task.updatedAt = nowIso();
297
+ this.store.writeTask(task);
298
+ if (!isTerminalTaskStatus(task.status)) this.active.set(key, task);
299
+ return this.refreshTaskLog(sessionId, taskId);
300
+ }
301
+
302
+ refreshTaskLog(sessionId, taskId) {
303
+ const key = this.#key(sessionId, taskId);
304
+ const task = this.active.get(key) || this.store.readTask(sessionId, taskId);
305
+ if (!task) return null;
306
+ const logPath = task.log?.path || this.store.logPath(sessionId, taskId);
307
+ const tail = this.store.readLogFile(logPath, { tail: true, maxBytes: LOG_PREVIEW_BYTES });
308
+ task.log = { ...(task.log || {}), path: tail.path, bytes: tail.bytes, preview: tail.text };
309
+ task.updatedAt = nowIso();
310
+ this.store.writeTask(task);
311
+ if (!isTerminalTaskStatus(task.status)) this.active.set(key, task);
312
+ this.#emit('updated', task);
313
+ return publicSnapshot(task);
314
+ }
315
+
316
+ renderActiveTasksForPrompt(sessionId = null) {
317
+ const tasks = this.listActiveTasks(sessionId);
318
+ if (tasks.length === 0) return '';
319
+ const lines = ['<active_tasks>'];
320
+ for (const task of tasks) {
321
+ const preview = (task.log?.preview || '').trim().split('\n').slice(-3).join(' | ');
322
+ 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)}` : ''}`);
323
+ }
324
+ lines.push('</active_tasks>');
325
+ return lines.join('\n');
326
+ }
327
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * shell-runner.js — Cross-platform background shell task runner.
3
+ */
4
+
5
+ import { spawn, spawnSync } from 'child_process';
6
+ import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
7
+
8
+ export function buildWindowsTaskkillArgs(pid) {
9
+ return ['/pid', String(pid), '/t', '/f'];
10
+ }
11
+
12
+ export function killShellProcessTree(pid, runtimePlatform, signal = 'SIGTERM') {
13
+ if (!pid) return false;
14
+ const platform = runtimePlatform || getRuntimePlatformInfo();
15
+ if (platform.isWindows) {
16
+ const result = spawnSync('taskkill.exe', buildWindowsTaskkillArgs(pid), {
17
+ windowsHide: true,
18
+ stdio: 'ignore',
19
+ });
20
+ return result.status === 0;
21
+ }
22
+
23
+ try {
24
+ process.kill(-pid, signal);
25
+ return true;
26
+ } catch {
27
+ try {
28
+ process.kill(pid, signal);
29
+ return true;
30
+ } catch {
31
+ return false;
32
+ }
33
+ }
34
+ }
35
+
36
+ export function startShellProcess({ command, cwd, runtimePlatform, onOutput, onExit, onError }) {
37
+ const platform = runtimePlatform || getRuntimePlatformInfo();
38
+ const invocation = buildShellInvocation(command, { runtimePlatform: platform });
39
+ const proc = spawn(invocation.command, invocation.args, {
40
+ cwd,
41
+ env: { ...process.env, TERM: 'dumb', FORCE_COLOR: '0' },
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ detached: !platform.isWindows,
44
+ windowsHide: true,
45
+ });
46
+
47
+ const write = (stream, chunk) => {
48
+ if (!chunk) return;
49
+ const text = Buffer.isBuffer(chunk) ? chunk.toString('utf8') : String(chunk);
50
+ onOutput?.(stream, text);
51
+ };
52
+
53
+ proc.stdout?.on('data', (chunk) => write('stdout', chunk));
54
+ proc.stderr?.on('data', (chunk) => write('stderr', chunk));
55
+ proc.on('error', (err) => onError?.(err));
56
+ proc.on('close', (code, signal) => onExit?.({ code, signal }));
57
+
58
+ return {
59
+ pid: proc.pid || null,
60
+ kill(signal = 'SIGTERM') {
61
+ return killShellProcessTree(proc.pid, platform, signal);
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * store.js — Persistent Session task metadata store.
3
+ *
4
+ * Tasks are Session-scoped runtime facts. Metadata is stored separately from
5
+ * logs so a noisy process cannot corrupt the task index.
6
+ */
7
+
8
+ import {
9
+ appendFileSync,
10
+ closeSync,
11
+ existsSync,
12
+ mkdirSync,
13
+ openSync,
14
+ readFileSync,
15
+ readSync,
16
+ readdirSync,
17
+ statSync,
18
+ writeFileSync,
19
+ } from 'fs';
20
+ import { join } from 'path';
21
+
22
+ export const TASK_STATUS = Object.freeze({
23
+ RUNNING: 'running',
24
+ SUCCEEDED: 'succeeded',
25
+ FAILED: 'failed',
26
+ CANCELLED: 'cancelled',
27
+ ORPHANED: 'orphaned',
28
+ });
29
+
30
+ const TERMINAL = new Set([
31
+ TASK_STATUS.SUCCEEDED,
32
+ TASK_STATUS.FAILED,
33
+ TASK_STATUS.CANCELLED,
34
+ TASK_STATUS.ORPHANED,
35
+ ]);
36
+
37
+ export function isTerminalTaskStatus(status) {
38
+ return TERMINAL.has(status);
39
+ }
40
+
41
+ function safeSessionId(sessionId) {
42
+ const raw = typeof sessionId === 'string' && sessionId.trim() ? sessionId.trim() : 'default';
43
+ return raw.replace(/[^a-zA-Z0-9._-]/g, '_');
44
+ }
45
+
46
+ function safeTaskId(taskId) {
47
+ const raw = typeof taskId === 'string' && taskId.trim() ? taskId.trim() : 'task_unknown';
48
+ return raw.replace(/[^a-zA-Z0-9._-]/g, '_');
49
+ }
50
+
51
+ export class TaskStore {
52
+ constructor({ yeaftDir }) {
53
+ if (!yeaftDir) throw new Error('TaskStore requires yeaftDir');
54
+ this.yeaftDir = yeaftDir;
55
+ this.root = join(yeaftDir, 'tasks', 'sessions');
56
+ }
57
+
58
+ sessionDir(sessionId) {
59
+ return join(this.root, safeSessionId(sessionId));
60
+ }
61
+
62
+ taskPath(sessionId, taskId) {
63
+ return join(this.sessionDir(sessionId), `${safeTaskId(taskId)}.json`);
64
+ }
65
+
66
+ logPath(sessionId, taskId) {
67
+ return join(this.sessionDir(sessionId), `${safeTaskId(taskId)}.log`);
68
+ }
69
+
70
+ eventPath(sessionId) {
71
+ return join(this.sessionDir(sessionId), 'tasks.jsonl');
72
+ }
73
+
74
+ ensureSessionDir(sessionId) {
75
+ const dir = this.sessionDir(sessionId);
76
+ mkdirSync(dir, { recursive: true, mode: 0o755 });
77
+ return dir;
78
+ }
79
+
80
+ writeTask(task) {
81
+ if (!task?.id) throw new Error('TaskStore.writeTask requires task.id');
82
+ const sessionId = task.sessionId || 'default';
83
+ this.ensureSessionDir(sessionId);
84
+ writeFileSync(this.taskPath(sessionId, task.id), `${JSON.stringify(task, null, 2)}\n`, { encoding: 'utf8', mode: 0o644 });
85
+ return task;
86
+ }
87
+
88
+ appendEvent(sessionId, event) {
89
+ this.ensureSessionDir(sessionId);
90
+ appendFileSync(this.eventPath(sessionId), `${JSON.stringify({ ...event, at: event.at || new Date().toISOString() })}\n`, { encoding: 'utf8', mode: 0o644 });
91
+ }
92
+
93
+ appendLog(sessionId, taskId, chunk) {
94
+ if (typeof chunk !== 'string' || chunk.length === 0) return;
95
+ this.ensureSessionDir(sessionId);
96
+ appendFileSync(this.logPath(sessionId, taskId), chunk, { encoding: 'utf8', mode: 0o644 });
97
+ }
98
+
99
+ readLogFile(path, { offset = 0, maxBytes = 64 * 1024, tail = false } = {}) {
100
+ if (!existsSync(path)) return { path, text: '', bytes: 0, offset: 0, nextOffset: 0 };
101
+ const st = statSync(path);
102
+ const bytes = st.size;
103
+ let start = Math.max(0, Number.isFinite(offset) ? Math.floor(offset) : 0);
104
+ const cap = Math.max(0, Math.min(Number.isFinite(maxBytes) ? Math.floor(maxBytes) : 64 * 1024, 1024 * 1024));
105
+ if (tail) start = Math.max(0, bytes - cap);
106
+ const end = Math.min(bytes, start + cap);
107
+ const length = Math.max(0, end - start);
108
+ if (length === 0) {
109
+ return { path, text: '', bytes, offset: start, nextOffset: end, truncated: end < bytes };
110
+ }
111
+
112
+ const fd = openSync(path, 'r');
113
+ try {
114
+ const buf = Buffer.allocUnsafe(length);
115
+ const readBytes = readSync(fd, buf, 0, length, start);
116
+ return {
117
+ path,
118
+ text: buf.subarray(0, readBytes).toString('utf8'),
119
+ bytes,
120
+ offset: start,
121
+ nextOffset: start + readBytes,
122
+ truncated: start + readBytes < bytes,
123
+ };
124
+ } finally {
125
+ closeSync(fd);
126
+ }
127
+ }
128
+
129
+ readLog(sessionId, taskId, opts = {}) {
130
+ return this.readLogFile(this.logPath(sessionId, taskId), opts);
131
+ }
132
+
133
+ readTask(sessionId, taskId) {
134
+ const path = this.taskPath(sessionId, taskId);
135
+ if (!existsSync(path)) return null;
136
+ return JSON.parse(readFileSync(path, 'utf8'));
137
+ }
138
+
139
+ loadActiveTasks() {
140
+ if (!existsSync(this.root)) return [];
141
+ const out = [];
142
+ for (const sessionDirName of readdirSync(this.root, { withFileTypes: true })) {
143
+ if (!sessionDirName.isDirectory()) continue;
144
+ const sessionDir = join(this.root, sessionDirName.name);
145
+ for (const entry of readdirSync(sessionDir, { withFileTypes: true })) {
146
+ if (!entry.isFile() || !entry.name.endsWith('.json')) continue;
147
+ try {
148
+ const task = JSON.parse(readFileSync(join(sessionDir, entry.name), 'utf8'));
149
+ if (task && task.status === TASK_STATUS.RUNNING) out.push(task);
150
+ } catch {
151
+ // Ignore corrupt task metadata; one bad task must not block boot.
152
+ }
153
+ }
154
+ }
155
+ return out;
156
+ }
157
+
158
+ statLog(sessionId, taskId) {
159
+ const path = this.logPath(sessionId, taskId);
160
+ try {
161
+ const st = statSync(path);
162
+ return { path, bytes: st.size, mtimeMs: st.mtimeMs };
163
+ } catch {
164
+ return { path, bytes: 0, mtimeMs: 0 };
165
+ }
166
+ }
167
+ }
@@ -419,6 +419,15 @@ use it as the default workflow or call it repeatedly in a loop.`,
419
419
  const deps = ctx?.parentEngineDeps;
420
420
  if (deps && deps.adapter) {
421
421
  try {
422
+ const task = ctx?.taskManager?.startTask?.({
423
+ sessionId: callerScope.sessionId || ctx?.sessionId || 'default',
424
+ ownerVpId: callerScope.parentVpId || ctx?.currentVpId || null,
425
+ kind: 'sub_agent',
426
+ title: spec.mission || spec.task || name,
427
+ runtime: { subAgentId: agentId, name, cwd: agent.cwd },
428
+ source: { threadId: callerScope.parentThreadId || ctx?.threadId || 'main' },
429
+ });
430
+ if (task?.id) agent.taskId = task.id;
422
431
  startSubAgent(agent, deps);
423
432
  } catch (err) {
424
433
  agent.status = STATUS.FAILED;
@@ -449,6 +458,7 @@ use it as the default workflow or call it repeatedly in a loop.`,
449
458
  budget: spec.budget || null,
450
459
  status: agent.status,
451
460
  outputFile: agent.outputFile || null,
461
+ taskId: agent.taskId || null,
452
462
  liveness,
453
463
  stale: liveness.stale,
454
464
  stalled: liveness.stalled,
@@ -12,7 +12,9 @@ import { defineTool } from './types.js';
12
12
  import { spawn } from 'child_process';
13
13
  import { existsSync } from 'fs';
14
14
  import { resolve } from 'path';
15
- import { getRuntimePlatformInfo, resolveDefaultShell } from '../runtime-platform.js';
15
+ import { buildShellInvocation, getRuntimePlatformInfo } from '../runtime-platform.js';
16
+
17
+ export { buildShellInvocation };
16
18
 
17
19
  /** Max output size in bytes before truncation (256 KB). */
18
20
  const MAX_OUTPUT = 256 * 1024;
@@ -23,31 +25,6 @@ const DEFAULT_TIMEOUT_MS = 120_000;
23
25
  /** Max timeout in ms (10 minutes). */
24
26
  const MAX_TIMEOUT_MS = 600_000;
25
27
 
26
- /**
27
- * @param {string} command
28
- * @param {{ runtimePlatform?: object }} opts
29
- */
30
- export function buildShellInvocation(command, opts = {}) {
31
- const runtimePlatform = opts.runtimePlatform || getRuntimePlatformInfo();
32
- const shell = runtimePlatform.defaultShell
33
- ? {
34
- command: runtimePlatform.defaultShell,
35
- argsPrefix: Array.isArray(runtimePlatform.shellArgsPrefix) ? runtimePlatform.shellArgsPrefix : null,
36
- family: runtimePlatform.shellFamily,
37
- }
38
- : resolveDefaultShell({ platform: runtimePlatform.platform });
39
-
40
- const argsPrefix = Array.isArray(shell.argsPrefix)
41
- ? shell.argsPrefix
42
- : resolveDefaultShell({ platform: runtimePlatform.platform }).argsPrefix;
43
-
44
- return {
45
- command: shell.command,
46
- args: [...argsPrefix, command],
47
- family: shell.family || runtimePlatform.shellFamily || 'posix',
48
- };
49
- }
50
-
51
28
  /**
52
29
  * Run a command in a child process.
53
30
  * @returns {Promise<{ stdout: string, stderr: string, exitCode: number, timedOut: boolean }>}
@@ -169,7 +146,7 @@ Guidelines:
169
146
  - Large outputs are truncated at 256KB
170
147
  - Use absolute paths when possible
171
148
  - Avoid interactive commands (no stdin support)
172
- - For long-running tasks, consider redirecting output to a file
149
+ - Use background=true for long-running or persistent tasks that should survive across turns
173
150
  - stderr is captured separately and included in the result`,
174
151
  zh: `执行 Shell 命令并返回输出。
175
152
 
@@ -181,7 +158,7 @@ Guidelines:
181
158
  - 大输出在 256KB 处截断
182
159
  - 尽量使用绝对路径
183
160
  - 避免交互式命令(不支持 stdin)
184
- - 长时间任务建议重定向输出到文件
161
+ - 长时间或需要跨 turn 持续存在的任务使用 background=true
185
162
  - stderr 单独捕获并包含在结果中`
186
163
  },
187
164
  parameters: {
@@ -208,6 +185,20 @@ Guidelines:
208
185
  zh: `超时时间,单位毫秒(默认 ${DEFAULT_TIMEOUT_MS},最大 ${MAX_TIMEOUT_MS})`,
209
186
  },
210
187
  },
188
+ background: {
189
+ type: 'boolean',
190
+ description: {
191
+ en: 'Run as a persistent Session task and return immediately with a taskId and log path',
192
+ zh: '作为持久化 Session 后台任务运行,并立即返回 taskId 和日志路径',
193
+ },
194
+ },
195
+ taskTitle: {
196
+ type: 'string',
197
+ description: {
198
+ en: 'Human-readable title for the background task',
199
+ zh: '后台任务的人类可读标题',
200
+ },
201
+ },
211
202
  },
212
203
  required: ['command'],
213
204
  },
@@ -223,7 +214,7 @@ Guidelines:
223
214
  cmd.includes('> /dev/') || cmd.includes('chmod 000');
224
215
  },
225
216
  async execute(input, ctx) {
226
- const { command, cwd: inputCwd, timeout_ms } = input;
217
+ const { command, cwd: inputCwd, timeout_ms, background = false, taskTitle } = input;
227
218
  if (!command) return JSON.stringify({ error: 'command is required' });
228
219
 
229
220
  // Resolve working directory
@@ -239,6 +230,28 @@ Guidelines:
239
230
  const timeout = Math.min(Math.max(timeout_ms || DEFAULT_TIMEOUT_MS, 1000), MAX_TIMEOUT_MS);
240
231
  const runtimePlatform = ctx?.runtimePlatform || getRuntimePlatformInfo();
241
232
 
233
+ if (background) {
234
+ if (!ctx?.taskManager) {
235
+ return JSON.stringify({ error: 'background tasks are unavailable in this runtime' });
236
+ }
237
+ try {
238
+ const task = ctx.taskManager.startShellTask({
239
+ command,
240
+ cwd,
241
+ sessionId: ctx.sessionId || 'default',
242
+ ownerVpId: ctx.currentVpId || null,
243
+ title: taskTitle || command.slice(0, 120),
244
+ runtimePlatform,
245
+ source: {
246
+ threadId: ctx.threadId || 'main',
247
+ },
248
+ });
249
+ return `Started background task ${task.id}.\nStatus: ${task.status}\nLog: ${task.log?.path || ''}\nUse ListTasks, ReadTaskLog, or CancelTask to inspect or control it.`;
250
+ } catch (err) {
251
+ return JSON.stringify({ error: err?.message || String(err) });
252
+ }
253
+ }
254
+
242
255
  try {
243
256
  const result = await runCommand(command, {
244
257
  cwd,
@@ -0,0 +1,30 @@
1
+ /**
2
+ * cancel-task.js — Cancel a running background task.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+
7
+ export default defineTool({
8
+ name: 'CancelTask',
9
+ description: {
10
+ en: 'Cancel a running Session background task.',
11
+ zh: '取消正在运行的 Session 后台任务。',
12
+ },
13
+ parameters: {
14
+ type: 'object',
15
+ properties: {
16
+ taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
17
+ sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
18
+ },
19
+ required: ['taskId'],
20
+ },
21
+ isConcurrencySafe: () => false,
22
+ isReadOnly: () => false,
23
+ async execute(input = {}, ctx = {}) {
24
+ if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
25
+ const taskId = input.taskId;
26
+ if (!taskId) return JSON.stringify({ error: 'taskId is required' });
27
+ const sessionId = input.sessionId || ctx.sessionId || 'default';
28
+ return JSON.stringify(ctx.taskManager.cancelTask(sessionId, taskId), null, 2);
29
+ },
30
+ });
@@ -95,6 +95,14 @@ Do NOT end your turn silently right after CloseAgent.`,
95
95
  const wasTerminal = isTerminalAgentStatus(agent.status);
96
96
  if (!wasTerminal) {
97
97
  agent.status = STATUS.CLOSED;
98
+ if (agent.taskId && ctx?.taskManager && agent.parentSessionId) {
99
+ try {
100
+ ctx.taskManager.completeTask(agent.parentSessionId, agent.taskId, {
101
+ status: 'cancelled',
102
+ error: agent.error || null,
103
+ });
104
+ } catch { /* ignore */ }
105
+ }
98
106
  // The driver may not yet have observed the abort / status flip; push
99
107
  // a notification so the queue stays consistent (idempotent inside
100
108
  // the notifications module). The driver's own finalizeTerminal()
@@ -38,6 +38,9 @@ import globTool from './glob.js';
38
38
  import grepTool from './grep.js';
39
39
  import listDir from './list-dir.js';
40
40
  import applyPatch from './apply-patch.js';
41
+ import listTasks from './list-tasks.js';
42
+ import readTaskLog from './read-task-log.js';
43
+ import cancelTask from './cancel-task.js';
41
44
 
42
45
  // --- P1 Agent tools ---
43
46
  import agentTool from './agent.js';
@@ -99,6 +102,9 @@ export const allTools = [
99
102
  grepTool,
100
103
  listDir,
101
104
  applyPatch,
105
+ listTasks,
106
+ readTaskLog,
107
+ cancelTask,
102
108
 
103
109
  // P1 Agent
104
110
  agentTool,
@@ -0,0 +1,29 @@
1
+ /**
2
+ * list-tasks.js — List active Session background tasks.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+
7
+ export default defineTool({
8
+ name: 'ListTasks',
9
+ description: {
10
+ en: 'List currently running Session background tasks.',
11
+ zh: '列出当前正在运行的 Session 后台任务。',
12
+ },
13
+ parameters: {
14
+ type: 'object',
15
+ properties: {
16
+ sessionId: {
17
+ type: 'string',
18
+ description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' },
19
+ },
20
+ },
21
+ },
22
+ isConcurrencySafe: () => true,
23
+ isReadOnly: () => true,
24
+ async execute(input = {}, ctx = {}) {
25
+ if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
26
+ const sessionId = input.sessionId || ctx.sessionId || null;
27
+ return JSON.stringify({ tasks: ctx.taskManager.listActiveTasks(sessionId) }, null, 2);
28
+ },
29
+ });
@@ -0,0 +1,38 @@
1
+ /**
2
+ * read-task-log.js — Read a background task log.
3
+ */
4
+
5
+ import { defineTool } from './types.js';
6
+
7
+ export default defineTool({
8
+ name: 'ReadTaskLog',
9
+ description: {
10
+ en: 'Read a background task log by taskId. Supports tail reads and byte offsets.',
11
+ zh: '按 taskId 读取后台任务日志。支持 tail 和字节 offset。',
12
+ },
13
+ parameters: {
14
+ type: 'object',
15
+ properties: {
16
+ taskId: { type: 'string', description: { en: 'Task id', zh: '任务 ID' } },
17
+ sessionId: { type: 'string', description: { en: 'Session id (defaults to current Session)', zh: 'Session ID(默认当前 Session)' } },
18
+ offset: { type: 'number', description: { en: 'Byte offset to start reading from', zh: '开始读取的字节 offset' } },
19
+ maxBytes: { type: 'number', description: { en: 'Maximum bytes to read (max 1 MiB)', zh: '最多读取字节数(最大 1 MiB)' } },
20
+ tail: { type: 'boolean', description: { en: 'Read the last maxBytes bytes', zh: '读取最后 maxBytes 字节' } },
21
+ },
22
+ required: ['taskId'],
23
+ },
24
+ isConcurrencySafe: () => true,
25
+ isReadOnly: () => true,
26
+ async execute(input = {}, ctx = {}) {
27
+ if (!ctx.taskManager) return JSON.stringify({ error: 'task manager unavailable' });
28
+ const taskId = input.taskId;
29
+ if (!taskId) return JSON.stringify({ error: 'taskId is required' });
30
+ const sessionId = input.sessionId || ctx.sessionId || 'default';
31
+ const result = ctx.taskManager.readTaskLog(sessionId, taskId, {
32
+ offset: input.offset,
33
+ maxBytes: input.maxBytes,
34
+ tail: input.tail !== false,
35
+ });
36
+ return JSON.stringify(result, null, 2);
37
+ },
38
+ });
@@ -21,6 +21,9 @@
21
21
  * @property {object} [skillManager] — Skill manager
22
22
  * @property {object} [trace] — debug trace
23
23
  * @property {object} [config] — engine config
24
+ * @property {import('../tasks/manager.js').TaskManager} [taskManager] — Session task manager
25
+ * @property {string} [sessionId] — current Session id
26
+ * @property {string} [threadId] — current Session thread id
24
27
  * @property {string} [currentVpId] — R6: VP id of the caller (set in multi-VP groups)
25
28
  * @property {string} [currentGroupId] — R6: group id of the caller's RoleInstance
26
29
  * @property {(sessionId: string) => string[]|null} [getGroupRoster]
@@ -948,6 +948,7 @@ function getOrCreateVpEngine(sessionId, vpId, threadId = 'main') {
948
948
  // (`if (this.#toolStats && ...)`) is false and group VP tool calls
949
949
  // are silently dropped.
950
950
  toolStats: session.toolStats || null,
951
+ taskManager: session.taskManager || null,
951
952
  // Per-VP fan-out: bind the engine to its (sessionId, vpId) so post-turn
952
953
  // compact reads/writes a scoped summary instead of the legacy global
953
954
  // compact.md (which every VP would otherwise share, producing
@@ -1943,6 +1944,15 @@ function buildVpPersona(vpId) {
1943
1944
  export function installYeaftRuntimeBridge(s) {
1944
1945
  if (!s) return;
1945
1946
 
1947
+ if (s.taskManager && typeof s.taskManager.setEventSink === 'function') {
1948
+ s.taskManager.setEventSink((event) => {
1949
+ try {
1950
+ const sessionId = event?.task?.sessionId || event?.sessionId || null;
1951
+ sendSessionEvent(event, { sessionId });
1952
+ } catch { /* never let task event delivery throw */ }
1953
+ });
1954
+ }
1955
+
1946
1956
  // Forward dream pipeline progress events to the web debug panel.
1947
1957
  //
1948
1958
  // Group-id stamping is NO LONGER done here. It used to be: this sink
@@ -2896,6 +2906,7 @@ async function ensureSessionLoaded() {
2896
2906
  mcpServers: session.status.mcpServers,
2897
2907
  tools: session.status.tools,
2898
2908
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
2909
+ tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
2899
2910
  });
2900
2911
  sendSessionSnapshotBroadcast();
2901
2912
  // vp-status: rebuild frontend status table from authoritative agent
@@ -4152,6 +4163,7 @@ export async function handleYeaftLoadHistory(msg) {
4152
4163
  mcpServers: session.status.mcpServers,
4153
4164
  tools: session.status.tools,
4154
4165
  yeaftDir: ctx.CONFIG?.yeaftDir || null,
4166
+ tasks: session.taskManager ? session.taskManager.listActiveTasks() : [],
4155
4167
  });
4156
4168
  sendSessionSnapshotBroadcast();
4157
4169
  if (sessionId) {