tycono 0.1.89-beta.0 → 0.1.89-beta.1

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": "tycono",
3
- "version": "0.1.89-beta.0",
3
+ "version": "0.1.89-beta.1",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@ import { engineRouter } from './routes/engine.js';
21
21
  import { sessionsRouter } from './routes/sessions.js';
22
22
  import { setupRouter } from './routes/setup.js';
23
23
  import { getAllActivities, completeActivity } from './services/activity-tracker.js';
24
+ import { type RoleStatus, isRoleActive } from '../../shared/types';
24
25
  import { knowledgeRouter } from './routes/knowledge.js';
25
26
  import { preferencesRouter } from './routes/preferences.js';
26
27
  import { saveRouter } from './routes/save.js';
@@ -50,7 +51,7 @@ const corsOrigin = isProd ? true : /^http:\/\/localhost:\d+$/;
50
51
  function cleanupStaleActivities(): void {
51
52
  const activities = getAllActivities();
52
53
  for (const activity of activities) {
53
- if (activity.status === 'working') {
54
+ if (isRoleActive(activity.status as RoleStatus)) {
54
55
  completeActivity(activity.roleId);
55
56
  console.log(`[STARTUP] Cleaned stale activity: ${activity.roleId} (was working on "${activity.currentTask}")`);
56
57
  }
@@ -35,6 +35,8 @@ export interface AgentConfig {
35
35
  onDispatch?: (roleId: string, task: string) => void;
36
36
  onConsult?: (roleId: string, question: string) => void;
37
37
  onTurnComplete?: (turn: number) => void;
38
+ /** Trace: emitted when system prompt is assembled */
39
+ onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
38
40
  }
39
41
 
40
42
  export interface AgentResult {
@@ -124,6 +126,9 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
124
126
  // 1. Assemble context
125
127
  const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus: config.teamStatus, targetRoles: config.targetRoles });
126
128
 
129
+ // Trace: capture assembled prompt for debugging
130
+ config.onPromptAssembled?.(context.systemPrompt, task);
131
+
127
132
  // 2. Determine tools
128
133
  const subordinates = getSubordinates(orgTree, roleId);
129
134
  const hasBash = !readOnly && !!config.codeRoot;
@@ -42,7 +42,8 @@ export interface AssembledContext {
42
42
  * 8. CEO Decisions (전사 공지 — Approved 결정만)
43
43
  * 9. Task
44
44
  */
45
- export type TeamStatus = Record<string, { status: string; task?: string }>;
45
+ export type { TeamStatus } from '../../../shared/types';
46
+ import { type RoleStatus, type TeamStatus, isRoleActive } from '../../../shared/types';
46
47
 
47
48
  export function assembleContext(
48
49
  companyRoot: string,
@@ -461,7 +462,7 @@ function buildDispatchSection(orgTree: OrgTree, roleId: string, subordinates: st
461
462
 
462
463
  // Header: name, id, persona summary
463
464
  const st = teamStatus?.[id];
464
- const status = st?.status === 'working'
465
+ const status = st?.status && isRoleActive(st.status)
465
466
  ? `🔴 Working${st.task ? ` — "${st.task.slice(0, 60)}"` : ''}`
466
467
  : '🟢 Idle';
467
468
  lines.push(`### ${sub.name} (\`${id}\`) — ${status}`);
@@ -267,6 +267,9 @@ export class ClaudeCliRunner implements ExecutionRunner {
267
267
  // 1. Context Assembly
268
268
  const context = assembleContext(companyRoot, roleId, task, sourceRole, orgTree, { teamStatus, targetRoles });
269
269
 
270
+ // Trace: capture assembled prompt for debugging
271
+ callbacks.onPromptAssembled?.(context.systemPrompt, task);
272
+
270
273
  // 2. System prompt를 임시 파일로 저장 (CLI arg 길이 제한 대비)
271
274
  const tmpDir = path.join(os.tmpdir(), 'tycono-engine');
272
275
  fs.mkdirSync(tmpDir, { recursive: true });
@@ -47,6 +47,7 @@ export class DirectApiRunner implements ExecutionRunner {
47
47
  onDispatch: (roleId, task) => callbacks.onDispatch?.(roleId, task),
48
48
  onConsult: (roleId, question) => callbacks.onConsult?.(roleId, question),
49
49
  onTurnComplete: (turn) => callbacks.onTurnComplete?.(turn),
50
+ onPromptAssembled: (systemPrompt, userTask) => callbacks.onPromptAssembled?.(systemPrompt, userTask),
50
51
  }).then((agentResult): RunnerResult => ({
51
52
  output: agentResult.output,
52
53
  turns: agentResult.turns,
@@ -23,7 +23,8 @@ export interface ImageAttachment {
23
23
 
24
24
  /* ─── Config ──────────────────────────────────── */
25
25
 
26
- export type TeamStatus = Record<string, { status: string; task?: string }>;
26
+ export type { TeamStatus } from '../../../../shared/types';
27
+ import type { TeamStatus } from '../../../../shared/types';
27
28
 
28
29
  export interface RunnerConfig {
29
30
  companyRoot: string;
@@ -55,6 +56,8 @@ export interface RunnerCallbacks {
55
56
  onConsult?: (roleId: string, question: string) => void;
56
57
  onTurnComplete?: (turn: number) => void;
57
58
  onError?: (error: string) => void;
59
+ /** Trace: emitted when system prompt is assembled, for full prompt capture */
60
+ onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
58
61
  }
59
62
 
60
63
  /* ─── Result ──────────────────────────────────── */
@@ -14,6 +14,7 @@ import {
14
14
  type ImageAttachment,
15
15
  } from '../services/session-store.js';
16
16
  import { jobManager, type Job } from '../services/job-manager.js';
17
+ import { type JobStatus, type RoleStatus, type WaveRoleStatus, type TeamStatus, isJobActive, isRoleActive, jobStatusToRoleStatus, eventTypeToJobStatus } from '../../../shared/types';
17
18
  import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
18
19
  import { earnCoinsInternal } from './coins.js';
19
20
  import { appendFollowUpToWave } from '../services/wave-tracker.js';
@@ -32,7 +33,7 @@ function getRunner() {
32
33
 
33
34
  /* ─── Active execution tracking (legacy, kept for /api/exec/status compat) ──── */
34
35
 
35
- const roleStatus = new Map<string, 'idle' | 'working' | 'done'>();
36
+ const roleStatus = new Map<string, RoleStatus>();
36
37
 
37
38
  /* ─── Raw HTTP handler (Express 5 SSE 호환 문제 우회) ─── */
38
39
 
@@ -116,6 +117,21 @@ function handleJobsRequest(url: string, method: string, req: IncomingMessage, re
116
117
  return;
117
118
  }
118
119
 
120
+ // POST /api/jobs/:id/abort — abort a running job directly by jobId
121
+ const abortMatch = path.match(/^\/api\/jobs\/([^/]+)\/abort$/);
122
+ if (method === 'POST' && abortMatch) {
123
+ const jobId = abortMatch[1];
124
+ const success = jobManager.abortJob(jobId);
125
+ if (!success) {
126
+ res.writeHead(404);
127
+ res.end(JSON.stringify({ error: 'Job not found or not running' }));
128
+ } else {
129
+ res.writeHead(200, { 'Content-Type': 'application/json' });
130
+ res.end(JSON.stringify({ ok: true, jobId }));
131
+ }
132
+ return;
133
+ }
134
+
119
135
  // All other /api/jobs/* endpoints → 410
120
136
  res.writeHead(410);
121
137
  res.end(JSON.stringify({ error: 'Use /api/sessions/* for client-facing operations. /api/jobs/:id and /api/jobs/:id/history are internal only.' }));
@@ -311,9 +327,9 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
311
327
  roleId: string;
312
328
  roleName: string;
313
329
  jobId: string;
314
- status: string;
330
+ status: WaveRoleStatus;
315
331
  events: ReturnType<typeof ActivityStream.readAll>;
316
- childJobs: Array<{ roleId: string; roleName: string; jobId: string; status: string; events: ReturnType<typeof ActivityStream.readAll> }>;
332
+ childJobs: Array<{ roleId: string; roleName: string; jobId: string; status: WaveRoleStatus; events: ReturnType<typeof ActivityStream.readAll> }>;
317
333
  }
318
334
  const rolesData: WaveRoleData[] = [];
319
335
 
@@ -323,7 +339,7 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
323
339
  const roleId = startEvent?.roleId ?? 'unknown';
324
340
  const roleName = (startEvent?.data?.roleName as string) ?? roleId;
325
341
  const doneEvent = events.find(e => e.type === 'job:done' || e.type === 'job:awaiting_input' || e.type === 'job:error');
326
- const status = doneEvent?.type === 'job:done' ? 'done' : doneEvent?.type === 'job:error' ? 'error' : doneEvent?.type === 'job:awaiting_input' ? 'awaiting_input' : 'unknown';
342
+ const status: WaveRoleStatus = doneEvent ? eventTypeToJobStatus(doneEvent.type) as WaveRoleStatus : 'unknown';
327
343
 
328
344
  // Collect child jobs (dispatched sub-roles)
329
345
  const childJobs: WaveRoleData['childJobs'] = [];
@@ -333,7 +349,7 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
333
349
  const targetRoleId = (e.data.targetRoleId as string) ?? 'unknown';
334
350
  const childEvents = ActivityStream.readAll(childJobId);
335
351
  const childDone = childEvents.find(ce => ce.type === 'job:done' || ce.type === 'job:error' || ce.type === 'job:awaiting_input');
336
- const childStatus = childDone?.type === 'job:done' ? 'done' : childDone?.type === 'job:error' ? 'error' : 'unknown';
352
+ const childStatus: WaveRoleStatus = childDone ? eventTypeToJobStatus(childDone.type) as WaveRoleStatus : 'unknown';
337
353
  childJobs.push({
338
354
  roleId: targetRoleId,
339
355
  roleName: (childEvents.find(ce => ce.type === 'job:start')?.data?.roleName as string) ?? targetRoleId,
@@ -547,6 +563,9 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
547
563
  case 'stderr':
548
564
  sendSSE(res, 'stderr', { message: event.data.message });
549
565
  break;
566
+ case 'job:awaiting_input':
567
+ sendSSE(res, 'awaiting_input', { question: event.data.question, targetRole: event.data.targetRole, reason: event.data.reason });
568
+ break;
550
569
  case 'job:done':
551
570
  cleanupLifecycle();
552
571
  sendSSE(res, 'done', event.data);
@@ -568,6 +587,7 @@ function handleAssign(body: Record<string, unknown>, req: IncomingMessage, res:
568
587
  req.on('close', () => {
569
588
  cleanupLifecycle();
570
589
  job.stream.unsubscribe(subscriber);
590
+ roleStatus.set(roleId, 'idle');
571
591
  });
572
592
  }
573
593
 
@@ -645,6 +665,9 @@ function handleWave(body: Record<string, unknown>, req: IncomingMessage, res: Se
645
665
  case 'stderr':
646
666
  sendSSE(res, 'stderr', { roleId: rolePrefix, message: event.data.message });
647
667
  break;
668
+ case 'job:awaiting_input':
669
+ sendSSE(res, 'role:awaiting_input', { roleId: rolePrefix, question: event.data.question, targetRole: event.data.targetRole, reason: event.data.reason });
670
+ break;
648
671
  case 'job:done':
649
672
  sendSSE(res, 'role:done', { roleId: rolePrefix, ...event.data });
650
673
  doneCount++;
@@ -687,27 +710,31 @@ function handleStatus(res: ServerResponse): void {
687
710
  statuses[activity.roleId] = activity.status;
688
711
  }
689
712
 
690
- // 2. JobManager running jobs are the source of truth for "working"
691
- const runningJobs = jobManager.listJobs({ status: 'running' });
692
- const runningRoles = new Set(runningJobs.map(j => j.roleId));
713
+ // 2. JobManager active jobs (isJobActive: running | awaiting_input)
714
+ const activeJobs = jobManager.listJobs({ active: true });
715
+ const activeRoles = new Set(activeJobs.map(j => j.roleId));
693
716
 
694
717
  // 2b. In-memory roleStatus (includes chat streaming sessions, not just jobs)
695
718
  const memoryWorking = new Set<string>();
696
719
  for (const [rid, st] of roleStatus.entries()) {
697
- if (st === 'working') memoryWorking.add(rid);
720
+ if (isRoleActive(st as RoleStatus)) memoryWorking.add(rid);
698
721
  }
699
722
 
700
- // 3. Any role marked "working" in file but NOT in JobManager AND NOT in memory → done
723
+ // 3. Stale cleanup: active in file but NOT in JobManager AND NOT in memory → done
701
724
  for (const roleId of Object.keys(statuses)) {
702
- if (statuses[roleId] === 'working' && !runningRoles.has(roleId) && !memoryWorking.has(roleId)) {
725
+ const s = statuses[roleId];
726
+ if (isRoleActive(s as RoleStatus) && !activeRoles.has(roleId) && !memoryWorking.has(roleId)) {
703
727
  statuses[roleId] = 'done';
704
728
  completeActivity(roleId);
705
729
  }
706
730
  }
707
731
 
708
- // 4. Running jobs override everything
709
- for (const job of runningJobs) {
710
- statuses[job.roleId] = 'working';
732
+ // 4. Active jobs override — use jobStatusToRoleStatus() for canonical mapping
733
+ for (const job of activeJobs) {
734
+ const mappedStatus = jobStatusToRoleStatus(job.status as JobStatus);
735
+ // running job → 'working' always wins over awaiting_input
736
+ if (statuses[job.roleId] === 'working' && mappedStatus === 'awaiting_input') continue;
737
+ statuses[job.roleId] = mappedStatus;
711
738
  }
712
739
 
713
740
  // 5. In-memory working (chat streaming) also overrides
@@ -715,7 +742,7 @@ function handleStatus(res: ServerResponse): void {
715
742
  statuses[rid] = 'working';
716
743
  }
717
744
 
718
- const activeExecs = runningJobs.map((j) => ({
745
+ const activeExecs = activeJobs.map((j) => ({
719
746
  id: j.id,
720
747
  roleId: j.roleId,
721
748
  task: j.task,
@@ -885,6 +912,14 @@ function handleSessionMessage(
885
912
  input: event.data.input,
886
913
  });
887
914
  break;
915
+ case 'job:awaiting_input':
916
+ sendSSE(res, 'dispatch:progress', {
917
+ roleId: event.roleId,
918
+ type: 'awaiting_input',
919
+ question: event.data.question,
920
+ targetRole: event.data.targetRole,
921
+ });
922
+ break;
888
923
  case 'job:done':
889
924
  sendSSE(res, 'dispatch:progress', {
890
925
  roleId: event.roleId,
@@ -906,15 +941,18 @@ function handleSessionMessage(
906
941
  childSubscriptions.push({ job: childJob, subscriber });
907
942
  });
908
943
 
909
- // Build team status from running jobs (same as JobManager pattern)
910
- const teamStatus: Record<string, { status: string; task?: string }> = {};
911
- for (const j of jobManager.listJobs({ status: 'running' })) {
912
- teamStatus[j.roleId] = { status: 'working', task: j.task };
944
+ // Build team status from active jobs using schema helpers
945
+ const teamStatus: TeamStatus = {};
946
+ for (const j of jobManager.listJobs({ active: true })) {
947
+ const mapped = jobStatusToRoleStatus(j.status as JobStatus);
948
+ // 'working' takes priority over 'awaiting_input' for same role
949
+ if (teamStatus[j.roleId]?.status === 'working' && mapped === 'awaiting_input') continue;
950
+ teamStatus[j.roleId] = { status: mapped, task: j.task };
913
951
  }
914
952
  // Also include roleStatus for roles working via session (not tracked as jobs)
915
953
  for (const [rid, status] of roleStatus) {
916
- if (status === 'working' && rid !== roleId && !teamStatus[rid]) {
917
- teamStatus[rid] = { status: 'working' };
954
+ if (isRoleActive(status as RoleStatus) && rid !== roleId && !teamStatus[rid]) {
955
+ teamStatus[rid] = { status: status as RoleStatus };
918
956
  }
919
957
  }
920
958
 
@@ -1,8 +1,10 @@
1
1
  import { Router, Request, Response, NextFunction } from 'express';
2
2
  import { readFile, listFiles, fileExists, COMPANY_ROOT } from '../services/file-reader.js';
3
3
  import { extractBoldKeyValues } from '../services/markdown-parser.js';
4
+ import { ActivityStream } from '../services/activity-stream.js';
4
5
  import path from 'node:path';
5
6
  import fs from 'node:fs';
7
+ import { type JobStatus, isJobActive } from '../../../shared/types';
6
8
 
7
9
  export const operationsRouter = Router();
8
10
 
@@ -50,7 +52,7 @@ operationsRouter.get('/waves', (_req: Request, res: Response, next: NextFunction
50
52
  try {
51
53
  const data = JSON.parse(readFile(`operations/waves/${f}`));
52
54
  const roles = data.roles ?? [];
53
- const hasRunning = roles.some((r: { status?: string }) => r.status === 'running' || r.status === 'awaiting_input');
55
+ const hasRunning = roles.some((r: { status?: string }) => r.status && isJobActive(r.status as JobStatus));
54
56
  return {
55
57
  id,
56
58
  timestamp: id,
@@ -174,3 +176,133 @@ operationsRouter.delete('/decisions/:id', (req: Request, res: Response, next: Ne
174
176
  next(err);
175
177
  }
176
178
  });
179
+
180
+ // --- Traces (AI-readable agent conversation debugging) ---
181
+
182
+ /**
183
+ * GET /api/ops/traces/:jobId — Dump full trace for a job
184
+ * Returns all events including full prompt/response for the job
185
+ * and all child jobs in the trace chain.
186
+ *
187
+ * Query params:
188
+ * ?chain=true — include all jobs in the same trace (default: true)
189
+ * ?type=trace — filter to trace:prompt and trace:response events only
190
+ */
191
+ operationsRouter.get('/traces/:jobId', (req: Request, res: Response, next: NextFunction) => {
192
+ try {
193
+ const jobId = String(req.params.jobId);
194
+ const includeChain = String(req.query.chain ?? 'true') !== 'false';
195
+ const typeFilter = String(req.query.type ?? '') || undefined;
196
+
197
+ // Read the target job's events
198
+ const events = ActivityStream.readAll(jobId);
199
+ if (events.length === 0) {
200
+ res.status(404).json({ error: `No activity stream found for job: ${jobId}` });
201
+ return;
202
+ }
203
+
204
+ // Extract traceId from the first event
205
+ const traceId = events[0]?.traceId ?? events.find(e => e.data?.traceId)?.data?.traceId as string ?? jobId;
206
+
207
+ if (!includeChain) {
208
+ const filtered = typeFilter
209
+ ? events.filter(e => e.type.startsWith(typeFilter))
210
+ : events;
211
+ res.json({ traceId, jobs: [{ jobId, events: filtered }] });
212
+ return;
213
+ }
214
+
215
+ // Find all jobs in the same trace
216
+ const allJobIds = ActivityStream.listAll();
217
+ const traceJobs: Array<{ jobId: string; roleId: string; events: typeof events }> = [];
218
+
219
+ for (const jid of allJobIds) {
220
+ const jobEvents = ActivityStream.readAll(jid);
221
+ const startEvent = jobEvents.find(e => e.type === 'job:start');
222
+ const jobTraceId = jobEvents[0]?.traceId ?? startEvent?.data?.traceId;
223
+
224
+ if (jobTraceId === traceId || jid === jobId) {
225
+ const filtered = typeFilter
226
+ ? jobEvents.filter(e => e.type.startsWith(typeFilter))
227
+ : jobEvents;
228
+ traceJobs.push({
229
+ jobId: jid,
230
+ roleId: startEvent?.roleId ?? 'unknown',
231
+ events: filtered,
232
+ });
233
+ }
234
+ }
235
+
236
+ // Sort by timestamp of first event
237
+ traceJobs.sort((a, b) => {
238
+ const aTs = a.events[0]?.ts ?? '';
239
+ const bTs = b.events[0]?.ts ?? '';
240
+ return aTs.localeCompare(bTs);
241
+ });
242
+
243
+ res.json({ traceId, jobCount: traceJobs.length, jobs: traceJobs });
244
+ } catch (err) {
245
+ next(err);
246
+ }
247
+ });
248
+
249
+ /**
250
+ * GET /api/ops/traces — List recent traces (grouped by traceId)
251
+ * Query params:
252
+ * ?limit=20 — max traces to return
253
+ * ?roleId=cto — filter by role
254
+ */
255
+ operationsRouter.get('/traces', (req: Request, res: Response, next: NextFunction) => {
256
+ try {
257
+ const limit = parseInt(String(req.query.limit ?? '20')) || 20;
258
+ const roleFilter = req.query.roleId ? String(req.query.roleId) : undefined;
259
+
260
+ const allJobIds = ActivityStream.listAll();
261
+ const traces = new Map<string, {
262
+ traceId: string;
263
+ startedAt: string;
264
+ rootRole: string;
265
+ rootTask: string;
266
+ jobCount: number;
267
+ status: string;
268
+ }>();
269
+
270
+ for (const jid of allJobIds) {
271
+ const events = ActivityStream.readAll(jid);
272
+ const startEvent = events.find(e => e.type === 'job:start');
273
+ if (!startEvent) continue;
274
+
275
+ const traceId = events[0]?.traceId ?? startEvent?.data?.traceId as string ?? jid;
276
+ if (roleFilter && startEvent.roleId !== roleFilter) continue;
277
+
278
+ if (!traces.has(traceId)) {
279
+ const doneEvent = events.find(e => e.type === 'job:done');
280
+ const errorEvent = events.find(e => e.type === 'job:error');
281
+ const awaitingEvent = events.find(e => e.type === 'job:awaiting_input');
282
+ const status = awaitingEvent ? 'awaiting_input'
283
+ : doneEvent ? 'done'
284
+ : errorEvent ? 'error'
285
+ : 'running';
286
+
287
+ traces.set(traceId, {
288
+ traceId,
289
+ startedAt: startEvent.ts,
290
+ rootRole: startEvent.roleId,
291
+ rootTask: (startEvent.data.task as string ?? '').slice(0, 200),
292
+ jobCount: 1,
293
+ status,
294
+ });
295
+ } else {
296
+ traces.get(traceId)!.jobCount++;
297
+ }
298
+ }
299
+
300
+ const sorted = [...traces.values()]
301
+ .sort((a, b) => b.startedAt.localeCompare(a.startedAt))
302
+ .slice(0, limit);
303
+
304
+ res.json(sorted);
305
+ } catch (err) {
306
+ next(err);
307
+ }
308
+ });
@@ -11,6 +11,7 @@ import {
11
11
  type Message,
12
12
  } from '../services/session-store.js';
13
13
  import { jobManager } from '../services/job-manager.js';
14
+ import { isJobActive } from '../../../shared/types';
14
15
  import { ActivityStream, type ActivityEvent } from '../services/activity-stream.js';
15
16
  import { updateFollowUpForReply } from '../services/wave-tracker.js';
16
17
 
@@ -118,7 +119,7 @@ sessionsRouter.get('/:id/stream', (req, res) => {
118
119
  }
119
120
 
120
121
  // If the job is finished or doesn't exist, end
121
- if (!job || (job.status !== 'running' && job.status !== 'awaiting_input')) {
122
+ if (!job || !isJobActive(job.status)) {
122
123
  sendEvent('stream:end', { reason: job ? job.status : 'no-job' });
123
124
  res.end();
124
125
  return;
@@ -159,12 +160,8 @@ sessionsRouter.get('/:id/stream', (req, res) => {
159
160
 
160
161
  /** POST /api/sessions/:id/abort — abort linked job */
161
162
  sessionsRouter.post('/:id/abort', (req, res) => {
162
- const session = getSession(req.params.id);
163
- if (!session) {
164
- res.status(404).json({ error: 'Session not found' });
165
- return;
166
- }
167
-
163
+ // Try session-based lookup first, then fallback to direct job search
164
+ // (session file may not exist after server restart, but job can still be in-memory)
168
165
  const job = jobManager.getJobBySessionId(req.params.id);
169
166
  if (!job) {
170
167
  res.status(404).json({ error: 'No active job for this session' });
@@ -2,26 +2,10 @@ import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { COMPANY_ROOT } from './file-reader.js';
4
4
 
5
- /* ─── Types ──────────────────────────────── */
6
-
7
- export type ActivityEventType =
8
- | 'job:start' | 'job:done' | 'job:error'
9
- | 'job:awaiting_input' | 'job:reply'
10
- | 'text' | 'thinking'
11
- | 'tool:start' | 'tool:result'
12
- | 'dispatch:start' | 'dispatch:done'
13
- | 'turn:complete' | 'turn:warning' | 'turn:limit'
14
- | 'import:scan' | 'import:process' | 'import:created'
15
- | 'stderr';
16
-
17
- export interface ActivityEvent {
18
- seq: number;
19
- ts: string;
20
- type: ActivityEventType;
21
- roleId: string;
22
- parentJobId?: string;
23
- data: Record<string, unknown>;
24
- }
5
+ /* ─── Types (re-export from shared contract) ── */
6
+
7
+ export { type ActivityEventType, type ActivityEvent } from '../../../shared/types';
8
+ import type { ActivityEventType, ActivityEvent } from '../../../shared/types';
25
9
 
26
10
  /* ─── Constants ──────────────────────────── */
27
11
 
@@ -50,16 +34,19 @@ export class ActivityStream {
50
34
  readonly jobId: string;
51
35
  readonly roleId: string;
52
36
  readonly parentJobId?: string;
37
+ /** Trace ID for full chain tracking — top-level jobId propagated to all children */
38
+ readonly traceId?: string;
53
39
 
54
40
  private seq = 0;
55
41
  private subscribers = new Set<ActivitySubscriber>();
56
42
  private filePath: string;
57
43
  private closed = false;
58
44
 
59
- constructor(jobId: string, roleId: string, parentJobId?: string) {
45
+ constructor(jobId: string, roleId: string, parentJobId?: string, traceId?: string) {
60
46
  this.jobId = jobId;
61
47
  this.roleId = roleId;
62
48
  this.parentJobId = parentJobId;
49
+ this.traceId = traceId;
63
50
 
64
51
  ensureDir();
65
52
  this.filePath = streamPath(jobId);
@@ -75,6 +62,7 @@ export class ActivityStream {
75
62
  type,
76
63
  roleId,
77
64
  parentJobId: this.parentJobId,
65
+ ...(this.traceId && { traceId: this.traceId }),
78
66
  data,
79
67
  };
80
68
 
@@ -1,6 +1,7 @@
1
1
  import fs from 'node:fs';
2
2
  import path from 'node:path';
3
3
  import { COMPANY_ROOT } from './file-reader.js';
4
+ import type { RoleStatus } from '../../../shared/types';
4
5
 
5
6
  function activityDir(): string {
6
7
  return path.join(COMPANY_ROOT, 'operations', 'activity');
@@ -8,7 +9,7 @@ function activityDir(): string {
8
9
 
9
10
  export interface RoleActivity {
10
11
  roleId: string;
11
- status: 'idle' | 'working' | 'done';
12
+ status: RoleStatus;
12
13
  currentTask: string;
13
14
  startedAt: string;
14
15
  updatedAt: string;
@@ -49,6 +50,15 @@ export function updateActivity(roleId: string, output: string): void {
49
50
  invalidateCache();
50
51
  }
51
52
 
53
+ export function markAwaitingInput(roleId: string): void {
54
+ const activity = getActivity(roleId);
55
+ if (!activity) return;
56
+ activity.status = 'awaiting_input';
57
+ activity.updatedAt = new Date().toISOString();
58
+ fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
59
+ invalidateCache();
60
+ }
61
+
52
62
  export function completeActivity(roleId: string): void {
53
63
  const activity = getActivity(roleId);
54
64
  if (!activity) return;