tycono 0.1.92 → 0.1.93-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.92",
3
+ "version": "0.1.93-beta.0",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -20,8 +20,7 @@ import { handleExecRequest } from './routes/execute.js';
20
20
  import { engineRouter } from './routes/engine.js';
21
21
  import { sessionsRouter } from './routes/sessions.js';
22
22
  import { setupRouter } from './routes/setup.js';
23
- import { getAllActivities, completeActivity } from './services/activity-tracker.js';
24
- import { type RoleStatus, isRoleActive } from '../../shared/types.js';
23
+ // activity-tracker removed executionManager resets on restart
25
24
  import { knowledgeRouter } from './routes/knowledge.js';
26
25
  import { preferencesRouter } from './routes/preferences.js';
27
26
  import { saveRouter } from './routes/save.js';
@@ -43,21 +42,6 @@ const __dirname = path.dirname(__filename);
43
42
  const isProd = process.env.NODE_ENV === 'production';
44
43
  const corsOrigin = isProd ? true : /^http:\/\/localhost:\d+$/;
45
44
 
46
- /**
47
- * 서버 시작 시 stale "working" activity 파일을 정리한다.
48
- * tsx watch 모드에서 재시작 시 in-memory 상태가 초기화되어도
49
- * 파일이 "working"으로 남는 버그를 방지한다.
50
- */
51
- function cleanupStaleActivities(): void {
52
- const activities = getAllActivities();
53
- for (const activity of activities) {
54
- if (isRoleActive(activity.status as RoleStatus)) {
55
- completeActivity(activity.roleId);
56
- console.log(`[STARTUP] Cleaned stale activity: ${activity.roleId} (was working on "${activity.currentTask}")`);
57
- }
58
- }
59
- }
60
-
61
45
  /* ─── Raw HTTP handler for import-knowledge SSE ─── */
62
46
 
63
47
  function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerResponse): void {
@@ -109,7 +93,6 @@ function handleImportKnowledge(req: http.IncomingMessage, res: http.ServerRespon
109
93
  export function createHttpServer(): http.Server {
110
94
  // Only cleanup/ensure if a company is already initialized (avoid creating dirs in CWD)
111
95
  if (COMPANY_ROOT && fs.existsSync(path.join(COMPANY_ROOT, 'CLAUDE.md'))) {
112
- cleanupStaleActivities();
113
96
  ensureClaudeMd(COMPANY_ROOT);
114
97
  }
115
98
 
@@ -133,6 +116,16 @@ export function createHttpServer(): http.Server {
133
116
  return;
134
117
  }
135
118
 
119
+ // D-014: POST /api/sessions/:id/message — delegate to execute handler for SSE streaming
120
+ const sessionMessageMatch = url.match(/^\/api\/sessions\/([^/]+)\/message$/);
121
+ if (sessionMessageMatch && method === 'POST') {
122
+ setExecCors(req, res);
123
+ // Rewrite URL to legacy format for handleExecRequest
124
+ req.url = `/api/exec/session/${sessionMessageMatch[1]}/message`;
125
+ handleExecRequest(req, res);
126
+ return;
127
+ }
128
+
136
129
  // SSE 엔드포인트: Express 우회하여 raw HTTP로 처리
137
130
  if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url === '/api/waves/save' || url === '/api/setup/import-knowledge') && method === 'POST') {
138
131
  setExecCors(req, res);
@@ -144,8 +137,8 @@ export function createHttpServer(): http.Server {
144
137
  return;
145
138
  }
146
139
 
147
- // CORS preflight for exec/jobs endpoints
148
- if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs')) && method === 'OPTIONS') {
140
+ // CORS preflight for exec/jobs/sessions endpoints
141
+ if ((url.startsWith('/api/exec/') || url.startsWith('/api/jobs') || url.match(/^\/api\/sessions\/[^/]+\/message$/)) && method === 'OPTIONS') {
149
142
  setExecCors(req, res);
150
143
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, DELETE, OPTIONS');
151
144
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -24,7 +24,7 @@ export interface AgentConfig {
24
24
  visitedRoles?: Set<string>; // Circular dispatch detection
25
25
  abortSignal?: AbortSignal; // Abort signal for cancellation
26
26
  teamStatus?: TeamStatus; // Current team member statuses
27
- jobId?: string; // Job ID for token tracking
27
+ sessionId: string; // D-014: Session ID for token tracking (required)
28
28
  model?: string; // LLM model name for cost tracking
29
29
  tokenLedger?: TokenLedger; // Token usage ledger (optional)
30
30
  attachments?: ImageAttachment[]; // Image attachments for vision
@@ -169,7 +169,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
169
169
  depth: depth + 1,
170
170
  visitedRoles: new Set(visitedRoles), // Copy for parallel dispatch support
171
171
  abortSignal,
172
- jobId: config.jobId,
172
+ sessionId: config.sessionId,
173
173
  model: config.model,
174
174
  tokenLedger: config.tokenLedger,
175
175
  onText: (text) => onText?.(`[${targetRoleId}] ${text}`),
@@ -210,7 +210,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
210
210
  depth: depth + 1,
211
211
  visitedRoles: new Set(visitedRoles),
212
212
  abortSignal,
213
- jobId: config.jobId,
213
+ sessionId: config.sessionId,
214
214
  model: config.model,
215
215
  tokenLedger: config.tokenLedger,
216
216
  onText: (text) => onText?.(`[consult:${targetRoleId}] ${text}`),
@@ -288,7 +288,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
288
288
  // Record token usage
289
289
  config.tokenLedger?.record({
290
290
  ts: new Date().toISOString(),
291
- jobId: config.jobId ?? 'unknown',
291
+ sessionId: config.sessionId,
292
292
  roleId,
293
293
  model: config.model ?? 'unknown',
294
294
  inputTokens: response.usage.inputTokens,
@@ -452,7 +452,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
452
452
  totalOutput += supResponse.usage.outputTokens;
453
453
  config.tokenLedger?.record({
454
454
  ts: new Date().toISOString(),
455
- jobId: config.jobId ?? 'unknown',
455
+ sessionId: config.sessionId,
456
456
  roleId,
457
457
  model: config.model ?? 'unknown',
458
458
  inputTokens: supResponse.usage.inputTokens,
@@ -532,7 +532,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
532
532
  totalOutput += verifyResponse.usage.outputTokens;
533
533
  config.tokenLedger?.record({
534
534
  ts: new Date().toISOString(),
535
- jobId: config.jobId ?? 'unknown',
535
+ sessionId: config.sessionId,
536
536
  roleId,
537
537
  model: config.model ?? 'unknown',
538
538
  inputTokens: verifyResponse.usage.inputTokens,
@@ -578,7 +578,7 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
578
578
  totalOutput += summaryResponse.usage.outputTokens;
579
579
  config.tokenLedger?.record({
580
580
  ts: new Date().toISOString(),
581
- jobId: config.jobId ?? 'unknown',
581
+ sessionId: config.sessionId,
582
582
  roleId,
583
583
  model: config.model ?? 'unknown',
584
584
  inputTokens: summaryResponse.usage.inputTokens,
@@ -26,7 +26,9 @@ export interface PostKnowledgingResult {
26
26
  export interface DecayReport {
27
27
  health: number;
28
28
  orphanDocs: string[];
29
+ staleDocs: string[];
29
30
  brokenLinks: Array<{ file: string; link: string }>;
31
+ suggestions: string[];
30
32
  totalDocs: number;
31
33
  linkedDocs: number;
32
34
  }
@@ -257,6 +259,7 @@ export function postKnowledgingCheck(
257
259
  export function detectDecay(companyRoot: string): DecayReport {
258
260
  const searchDirs = ['knowledge', 'architecture'];
259
261
  const orphanDocs: string[] = [];
262
+ const staleDocs: string[] = [];
260
263
  const brokenLinks: Array<{ file: string; link: string }> = [];
261
264
  let totalDocs = 0;
262
265
  let linkedDocs = 0;
@@ -282,10 +285,20 @@ export function detectDecay(companyRoot: string): DecayReport {
282
285
  linkedDocs++;
283
286
  }
284
287
 
285
- // Check for broken links in the file
288
+ // Check for broken links and stale status in the file
286
289
  const filePath = path.join(dirPath, file);
287
290
  try {
288
291
  const content = fs.readFileSync(filePath, 'utf-8');
292
+
293
+ // Check for deprecated/stale status in frontmatter
294
+ const frontmatterMatch = content.match(/^---\n([\s\S]*?)\n---/);
295
+ if (frontmatterMatch) {
296
+ const frontmatter = frontmatterMatch[1];
297
+ if (/status:\s*(deprecated|stale)/i.test(frontmatter)) {
298
+ staleDocs.push(path.join(dir, file));
299
+ }
300
+ }
301
+
289
302
  const linkRegex = /\[.*?\]\(\.\/(.*?\.md)\)/g;
290
303
  let match;
291
304
  while ((match = linkRegex.exec(content)) !== null) {
@@ -322,13 +335,30 @@ export function detectDecay(companyRoot: string): DecayReport {
322
335
  }
323
336
 
324
337
  const health = totalDocs > 0
325
- ? Math.round(((totalDocs - orphanDocs.length - brokenLinks.length) / totalDocs) * 100)
338
+ ? Math.round(((totalDocs - orphanDocs.length - staleDocs.length - brokenLinks.length) / totalDocs) * 100)
326
339
  : 100;
327
340
 
341
+ // Build suggestions
342
+ const suggestions: string[] = [];
343
+ if (orphanDocs.length > 0) {
344
+ suggestions.push(`${orphanDocs.length}개의 고아 문서를 Hub에 등록하세요`);
345
+ }
346
+ if (staleDocs.length > 0) {
347
+ suggestions.push(`${staleDocs.length}개의 오래된 문서를 업데이트하거나 삭제하세요`);
348
+ }
349
+ if (brokenLinks.length > 0) {
350
+ suggestions.push(`${brokenLinks.length}개의 깨진 링크를 수정하세요`);
351
+ }
352
+ if (orphanDocs.length === 0 && staleDocs.length === 0 && brokenLinks.length === 0) {
353
+ suggestions.push('모든 문서가 건강합니다! 🎉');
354
+ }
355
+
328
356
  return {
329
357
  health: Math.max(0, Math.min(100, health)),
330
358
  orphanDocs,
359
+ staleDocs,
331
360
  brokenLinks,
361
+ suggestions,
332
362
  totalDocs,
333
363
  linkedDocs,
334
364
  };
@@ -20,7 +20,8 @@ const DISPATCH_SCRIPT = `#!/usr/bin/env python3
20
20
 
21
21
  환경변수:
22
22
  DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
23
- DISPATCH_PARENT_JOB — 부모 Job ID (자동 설정)
23
+ DISPATCH_PARENT_SESSION — 부모 Session ID (자동 설정)
24
+ DISPATCH_PARENT_JOB — (deprecated) 부모 Job ID, DISPATCH_PARENT_SESSION 사용
24
25
  DISPATCH_SOURCE_ROLE — 현재 Role ID (자동 설정)
25
26
  """
26
27
  import sys, os, json, time, urllib.request, urllib.error
@@ -40,7 +41,7 @@ def get_result(job_id, retries=3):
40
41
  for e in events:
41
42
  if e['type'] == 'text':
42
43
  text_parts.append(e['data'].get('text', ''))
43
- elif e['type'] == 'job:error':
44
+ elif e['type'] in ('msg:error', 'job:error'):
44
45
  text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
45
46
  result = ''.join(text_parts)
46
47
  if result:
@@ -62,15 +63,15 @@ def get_status(job_id):
62
63
  return get_job_info(job_id).get('status', 'unknown')
63
64
 
64
65
  def start_job(role_id, task):
65
- parent_job = os.environ.get('DISPATCH_PARENT_JOB', '')
66
+ parent_session = os.environ.get('DISPATCH_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_JOB', ''))
66
67
  source_role = os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo')
67
68
  body = json.dumps({
68
69
  'type': 'assign',
69
70
  'roleId': role_id,
70
71
  'task': task,
71
72
  'sourceRole': source_role,
72
- 'parentJobId': parent_job if parent_job else None,
73
- }).encode()
73
+ 'parentSessionId': parent_session if parent_session else None,
74
+ }).encode()
74
75
  req = urllib.request.Request(f'{api}/api/jobs', body, {'Content-Type': 'application/json'})
75
76
  resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
76
77
  return resp['jobId']
@@ -164,7 +165,7 @@ def get_result(job_id, retries=3):
164
165
  for e in events:
165
166
  if e['type'] == 'text':
166
167
  text_parts.append(e['data'].get('text', ''))
167
- elif e['type'] == 'job:error':
168
+ elif e['type'] in ('msg:error', 'job:error'):
168
169
  text_parts.append('\\nERROR: ' + e['data'].get('message', ''))
169
170
  result = ''.join(text_parts)
170
171
  if result:
@@ -211,7 +212,7 @@ if len(sys.argv) < 3:
211
212
 
212
213
  role_id = sys.argv[1]
213
214
  question = ' '.join(sys.argv[2:])
214
- parent_job = os.environ.get('CONSULT_PARENT_JOB', os.environ.get('DISPATCH_PARENT_JOB', ''))
215
+ parent_session = os.environ.get('CONSULT_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_SESSION', os.environ.get('DISPATCH_PARENT_JOB', '')))
215
216
  source_role = os.environ.get('CONSULT_SOURCE_ROLE', os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo'))
216
217
 
217
218
  # Start job (readOnly + consult type)
@@ -222,7 +223,7 @@ body = json.dumps({
222
223
  'task': task,
223
224
  'sourceRole': source_role,
224
225
  'readOnly': True,
225
- 'parentJobId': parent_job if parent_job else None,
226
+ 'parentSessionId': parent_session if parent_session else None,
226
227
  }).encode()
227
228
 
228
229
  try:
@@ -344,9 +345,8 @@ export class ClaudeCliRunner implements ExecutionRunner {
344
345
  cleanEnv.DISPATCH_API_URL = `http://localhost:${apiPort}`;
345
346
  cleanEnv.DISPATCH_SOURCE_ROLE = roleId;
346
347
  cleanEnv.DISPATCH_SUBORDINATES = subordinates.join(', ');
347
- if (config.jobId) {
348
- cleanEnv.DISPATCH_PARENT_JOB = config.jobId;
349
- }
348
+ cleanEnv.DISPATCH_PARENT_SESSION = config.sessionId;
349
+ cleanEnv.DISPATCH_PARENT_JOB = config.sessionId; // deprecated, kept for backward compat
350
350
  // dispatch 명령어 경로를 PATH에 추가하지 않고 절대 경로로 사용
351
351
  cleanEnv.DISPATCH_CMD = dispatchScript;
352
352
  cleanEnv.CONSULT_CMD = consultScript;
@@ -360,7 +360,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
360
360
  // Inject repo paths so agents never confuse repos
361
361
  cleanEnv.TYCONO_CODE_ROOT = codeRoot;
362
362
  cleanEnv.TYCONO_AKB_ROOT = companyRoot;
363
- console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, jobId=${config.jobId ?? 'none'}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
363
+ console.log(`[Runner] Spawning claude -p: role=${roleId}, model=${modelName}, maxTurns=${maxTurns}, sessionId=${config.sessionId}, cwd=${cwd}, subordinates=[${subordinates.join(',')}]`);
364
364
 
365
365
  const proc = spawn('claude', args, {
366
366
  cwd,
@@ -422,7 +422,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
422
422
  addToolCall: (name, input) => {
423
423
  toolCalls.push({ name, input });
424
424
  // Dispatch detection removed — child jobs created by the Python
425
- // dispatch bridge script via POST /api/jobs with parentJobId.
425
+ // dispatch bridge script via POST /api/jobs with parentSessionId.
426
426
  // JobManager.startJob() now auto-emits dispatch:start on parent stream.
427
427
  },
428
428
  incrementTurn: () => { turnCount++; callbacks.onTurnComplete?.(turnCount); },
@@ -431,7 +431,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
431
431
  totalOutput += out;
432
432
  tokenLedger.record({
433
433
  ts: new Date().toISOString(),
434
- jobId: config.jobId ?? 'unknown',
434
+ sessionId: config.sessionId,
435
435
  roleId,
436
436
  model: modelName,
437
437
  inputTokens: input,
@@ -471,7 +471,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
471
471
  totalOutput += out;
472
472
  tokenLedger.record({
473
473
  ts: new Date().toISOString(),
474
- jobId: config.jobId ?? 'unknown',
474
+ sessionId: config.sessionId,
475
475
  roleId,
476
476
  model: modelName,
477
477
  inputTokens: input,
@@ -38,7 +38,7 @@ export class DirectApiRunner implements ExecutionRunner {
38
38
  codeRoot: config.codeRoot,
39
39
  llm: this.llm,
40
40
  abortSignal: abortController.signal,
41
- jobId: config.jobId,
41
+ sessionId: config.sessionId,
42
42
  model: config.model,
43
43
  tokenLedger,
44
44
  attachments: config.attachments,
@@ -35,7 +35,8 @@ export interface RunnerConfig {
35
35
  readOnly?: boolean;
36
36
  maxTurns?: number;
37
37
  model?: string;
38
- jobId?: string;
38
+ /** D-014: Session ID for tracking (required — primary identifier for token ledger). */
39
+ sessionId: string;
39
40
  teamStatus?: TeamStatus;
40
41
  attachments?: ImageAttachment[];
41
42
  /** Selective dispatch scope — only these roles can be dispatched to */
@@ -2,29 +2,25 @@
2
2
  * active-sessions.ts — Active session visibility API
3
3
  *
4
4
  * Exposes session + port + worktree state for both UI and AI agents.
5
- * All sessions sharing the same tycono server origin can query this.
6
5
  */
7
6
  import { Router } from 'express';
8
7
  import { portRegistry } from '../services/port-registry.js';
9
- import { jobManager } from '../services/job-manager.js';
8
+ import { executionManager } from '../services/execution-manager.js';
10
9
 
11
10
  export const activeSessionsRouter = Router();
12
11
 
13
12
  /**
14
13
  * GET /api/active-sessions
15
- * Returns all active sessions with port + worktree info.
16
- * Used by both the web UI and AI agents (curl).
17
14
  */
18
15
  activeSessionsRouter.get('/', (_req, res) => {
19
16
  const sessions = portRegistry.getAll();
20
17
 
21
- // Enrich with job info where available
22
18
  const enriched = sessions.map(s => {
23
- const job = jobManager.getJobInfo(s.sessionId);
19
+ const exec = executionManager.getActiveExecution(s.sessionId);
24
20
  return {
25
21
  ...s,
26
- jobStatus: job?.status ?? null,
27
- roleName: job?.roleId ?? s.roleId,
22
+ messageStatus: exec?.status ?? null,
23
+ roleName: exec?.roleId ?? s.roleId,
28
24
  alive: s.pid ? isAlive(s.pid) : null,
29
25
  };
30
26
  });
@@ -37,7 +33,6 @@ activeSessionsRouter.get('/', (_req, res) => {
37
33
 
38
34
  /**
39
35
  * GET /api/active-sessions/:id
40
- * Get detailed info for a specific session.
41
36
  */
42
37
  activeSessionsRouter.get('/:id', (req, res) => {
43
38
  const session = portRegistry.get(req.params.id);
@@ -46,20 +41,19 @@ activeSessionsRouter.get('/:id', (req, res) => {
46
41
  return;
47
42
  }
48
43
 
49
- const job = jobManager.getJobInfo(session.sessionId);
44
+ const exec = executionManager.getActiveExecution(session.sessionId);
50
45
 
51
46
  res.json({
52
47
  ...session,
53
- jobStatus: job?.status ?? null,
54
- roleName: job?.roleId ?? session.roleId,
48
+ messageStatus: exec?.status ?? null,
49
+ roleName: exec?.roleId ?? session.roleId,
55
50
  alive: session.pid ? isAlive(session.pid) : null,
56
- job: job ?? null,
51
+ execution: exec ? { id: exec.id, status: exec.status, roleId: exec.roleId, task: exec.task } : null,
57
52
  });
58
53
  });
59
54
 
60
55
  /**
61
56
  * DELETE /api/active-sessions/:id
62
- * Stop a session — release ports + clean up.
63
57
  */
64
58
  activeSessionsRouter.delete('/:id', (req, res) => {
65
59
  const sessionId = req.params.id;
@@ -70,10 +64,7 @@ activeSessionsRouter.delete('/:id', (req, res) => {
70
64
  return;
71
65
  }
72
66
 
73
- // Try to abort the job if running
74
- jobManager.abortJob(sessionId);
75
-
76
- // Release ports
67
+ executionManager.abortSession(sessionId);
77
68
  portRegistry.release(sessionId);
78
69
 
79
70
  res.json({ ok: true, released: session.ports });
@@ -81,7 +72,6 @@ activeSessionsRouter.delete('/:id', (req, res) => {
81
72
 
82
73
  /**
83
74
  * POST /api/active-sessions/cleanup
84
- * Clean up all dead sessions (PID gone).
85
75
  */
86
76
  activeSessionsRouter.post('/cleanup', (_req, res) => {
87
77
  const result = portRegistry.cleanup();
@@ -98,7 +88,6 @@ activeSessionsRouter.post('/cleanup', (_req, res) => {
98
88
 
99
89
  /**
100
90
  * POST /api/active-sessions/register
101
- * Manually register a session (for external Claude Code sessions).
102
91
  */
103
92
  activeSessionsRouter.post('/register', async (req, res) => {
104
93
  const { sessionId, roleId, task, pid, worktreePath } = req.body;
@@ -108,7 +97,6 @@ activeSessionsRouter.post('/register', async (req, res) => {
108
97
  return;
109
98
  }
110
99
 
111
- // Check if already registered
112
100
  const existing = portRegistry.get(sessionId);
113
101
  if (existing) {
114
102
  res.json({ ok: true, ports: existing.ports, existing: true });
@@ -61,6 +61,7 @@ costRouter.get('/summary', (req: Request, res: Response, next: NextFunction) =>
61
61
  });
62
62
 
63
63
  /* ── W-T602: GET /api/cost/jobs/:jobId ───── */
64
+ /* @deprecated D-014: use /api/cost/sessions/:sessionId */
64
65
 
65
66
  costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction) => {
66
67
  try {
@@ -96,3 +97,45 @@ costRouter.get('/jobs/:jobId', (req: Request, res: Response, next: NextFunction)
96
97
  next(err);
97
98
  }
98
99
  });
100
+
101
+ /* ── D-014: GET /api/cost/sessions/:sessionId ───── */
102
+
103
+ costRouter.get('/sessions/:sessionId', (req: Request, res: Response, next: NextFunction) => {
104
+ try {
105
+ const sessionId = req.params.sessionId as string;
106
+ const ledger = getTokenLedger(COMPANY_ROOT);
107
+
108
+ // D-014: Try sessionId field first, fall back to jobId for legacy entries
109
+ let summary = ledger.query({ sessionId });
110
+ if (summary.entries.length === 0) {
111
+ summary = ledger.query({ jobId: sessionId });
112
+ }
113
+
114
+ if (summary.entries.length === 0) {
115
+ res.status(404).json({ error: `No cost data found for session ${sessionId}` });
116
+ return;
117
+ }
118
+
119
+ let totalCostUsd = 0;
120
+ for (const entry of summary.entries) {
121
+ totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
122
+ }
123
+
124
+ res.json({
125
+ sessionId,
126
+ totalInputTokens: summary.totalInput,
127
+ totalOutputTokens: summary.totalOutput,
128
+ totalCostUsd,
129
+ entries: summary.entries.map((e) => ({
130
+ ts: e.ts,
131
+ roleId: e.roleId,
132
+ model: e.model,
133
+ inputTokens: e.inputTokens,
134
+ outputTokens: e.outputTokens,
135
+ costUsd: estimateCost(e.inputTokens, e.outputTokens, e.model),
136
+ })),
137
+ });
138
+ } catch (err) {
139
+ next(err);
140
+ }
141
+ });
@@ -200,6 +200,7 @@ engineRouter.post('/ask/:roleId', async (req: Request, res: Response, next: Next
200
200
  orgTree,
201
201
  readOnly: true,
202
202
  maxTurns: 5,
203
+ sessionId: `ask-${Date.now()}`,
203
204
  },
204
205
  {},
205
206
  );