tycono 0.1.69 → 0.1.71-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 +1 -1
- package/src/api/src/engine/context-assembler.ts +17 -6
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/routes/coins.ts +16 -0
- package/src/api/src/routes/execute.ts +36 -159
- package/src/api/src/routes/sessions.ts +157 -0
- package/src/api/src/services/job-manager.ts +143 -31
- package/src/api/src/services/preferences.ts +11 -0
- package/src/api/src/services/scaffold.ts +4 -0
- package/src/api/src/services/session-store.ts +4 -1
- package/src/web/dist/assets/index-BH7HI3Qh.css +1 -0
- package/src/web/dist/assets/index-u8NznRLU.js +109 -0
- package/src/web/dist/assets/{preview-app-D5KeKtZ7.js → preview-app-CXk17nrd.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-C5M-8dqq.css +0 -1
- package/src/web/dist/assets/index-DMWfd8DV.js +0 -109
package/package.json
CHANGED
|
@@ -155,13 +155,24 @@ Use the code repository path for all source code work (reading, writing, buildin
|
|
|
155
155
|
sections.push(consultSection);
|
|
156
156
|
}
|
|
157
157
|
|
|
158
|
-
// Language preference
|
|
158
|
+
// Language preference (default: English)
|
|
159
159
|
const prefs = readPreferences(companyRoot);
|
|
160
|
-
const lang = prefs.language
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
160
|
+
const lang = prefs.language && prefs.language !== 'auto' ? prefs.language : 'en';
|
|
161
|
+
const langNames: Record<string, string> = { en: 'English', ko: 'Korean (한국어)', ja: 'Japanese (日本語)' };
|
|
162
|
+
const langName = langNames[lang] ?? lang;
|
|
163
|
+
sections.push(`# Language (CRITICAL)
|
|
164
|
+
|
|
165
|
+
You MUST respond in **${langName}**.
|
|
166
|
+
|
|
167
|
+
This applies to ALL output without exception:
|
|
168
|
+
- Status updates, reports, and analysis
|
|
169
|
+
- Journal entries and standup notes
|
|
170
|
+
- Decision logs and knowledge documents
|
|
171
|
+
- User-facing messages and explanations
|
|
172
|
+
- Git commit messages and PR descriptions
|
|
173
|
+
|
|
174
|
+
Code (variable names, comments in code) may remain in English for readability.
|
|
175
|
+
Everything else MUST be in ${langName}.`);
|
|
165
176
|
|
|
166
177
|
// Execution behavior rules (prevents infinite exploration loops in -p mode)
|
|
167
178
|
sections.push(`# Execution Rules (CRITICAL)
|
|
@@ -41,6 +41,8 @@ export interface RunnerConfig {
|
|
|
41
41
|
targetRoles?: string[];
|
|
42
42
|
/** EG-001: Code project root for bash_execute tool */
|
|
43
43
|
codeRoot?: string;
|
|
44
|
+
/** PSM-004: Environment variables to inject (e.g., port assignments) */
|
|
45
|
+
env?: Record<string, string>;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
/* ─── Callbacks ───────────────────────────────── */
|
|
@@ -46,6 +46,22 @@ function writeCoins(data: CoinsData) {
|
|
|
46
46
|
writeFileSync(COINS_FILE(), JSON.stringify(data, null, 2) + '\n');
|
|
47
47
|
}
|
|
48
48
|
|
|
49
|
+
/* ── Internal API (for server-side use) ── */
|
|
50
|
+
|
|
51
|
+
export function earnCoinsInternal(amount: number, reason: string, ref?: string): { balance: number; skipped: boolean } {
|
|
52
|
+
const data = readCoins();
|
|
53
|
+
// Idempotency
|
|
54
|
+
if (ref && data.transactions.some(t => t.ref === ref && t.amount > 0)) {
|
|
55
|
+
return { balance: data.balance, skipped: true };
|
|
56
|
+
}
|
|
57
|
+
const tx: CoinTransaction = { ts: new Date().toISOString(), amount, reason, ref };
|
|
58
|
+
data.balance += amount;
|
|
59
|
+
data.totalEarned += amount;
|
|
60
|
+
data.transactions.push(tx);
|
|
61
|
+
writeCoins(data);
|
|
62
|
+
return { balance: data.balance, skipped: false };
|
|
63
|
+
}
|
|
64
|
+
|
|
49
65
|
/* ── Routes ── */
|
|
50
66
|
|
|
51
67
|
// GET /api/coins — current balance + summary
|
|
@@ -15,6 +15,7 @@ import {
|
|
|
15
15
|
} from '../services/session-store.js';
|
|
16
16
|
import { jobManager, type Job } from '../services/job-manager.js';
|
|
17
17
|
import { ActivityStream, type ActivityEvent, type ActivitySubscriber } from '../services/activity-stream.js';
|
|
18
|
+
import { earnCoinsInternal } from './coins.js';
|
|
18
19
|
|
|
19
20
|
/* ─── Runner — lazy, re-created when engine changes ── */
|
|
20
21
|
|
|
@@ -68,58 +69,27 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
|
|
|
68
69
|
═══════════════════════════════════════════════ */
|
|
69
70
|
|
|
70
71
|
function handleJobsRequest(url: string, method: string, req: IncomingMessage, res: ServerResponse): void {
|
|
71
|
-
|
|
72
|
-
const [path, queryString] = url.split('?');
|
|
72
|
+
const [path] = url.split('?');
|
|
73
73
|
|
|
74
|
-
// POST /api/jobs — start a new job
|
|
74
|
+
// POST /api/jobs — start a new job (creates session + job)
|
|
75
75
|
if (method === 'POST' && path === '/api/jobs') {
|
|
76
76
|
readBody(req).then((body) => handleStartJob(body, res));
|
|
77
77
|
return;
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
-
// GET /api/jobs —
|
|
81
|
-
if (method === 'GET' && path === '/api/jobs') {
|
|
82
|
-
const params = new URLSearchParams(queryString ?? '');
|
|
83
|
-
handleListJobs(params, res);
|
|
84
|
-
return;
|
|
85
|
-
}
|
|
86
|
-
|
|
87
|
-
// Match /api/jobs/:id/stream
|
|
88
|
-
const streamMatch = path.match(/^\/api\/jobs\/([^/]+)\/stream$/);
|
|
89
|
-
if (streamMatch && method === 'GET') {
|
|
90
|
-
const params = new URLSearchParams(queryString ?? '');
|
|
91
|
-
const fromSeq = parseInt(params.get('from') ?? '0', 10);
|
|
92
|
-
handleJobStream(streamMatch[1], fromSeq, req, res);
|
|
93
|
-
return;
|
|
94
|
-
}
|
|
95
|
-
|
|
96
|
-
// Match /api/jobs/:id/reply
|
|
97
|
-
const replyMatch = path.match(/^\/api\/jobs\/([^/]+)\/reply$/);
|
|
98
|
-
if (replyMatch && method === 'POST') {
|
|
99
|
-
readBody(req).then((body) => handleReplyToJob(replyMatch[1], body, res));
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
102
|
-
|
|
103
|
-
// Match /api/jobs/:id/history
|
|
80
|
+
// GET /api/jobs/:id/history — internal only (used by engine Python sub-processes)
|
|
104
81
|
const historyMatch = path.match(/^\/api\/jobs\/([^/]+)\/history$/);
|
|
105
|
-
if (
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
// Match /api/jobs/:id
|
|
111
|
-
const idMatch = path.match(/^\/api\/jobs\/([^/]+)$/);
|
|
112
|
-
if (idMatch && method === 'GET') {
|
|
113
|
-
handleGetJob(idMatch[1], res);
|
|
114
|
-
return;
|
|
115
|
-
}
|
|
116
|
-
if (idMatch && method === 'DELETE') {
|
|
117
|
-
handleAbortJob(idMatch[1], res);
|
|
82
|
+
if (method === 'GET' && historyMatch) {
|
|
83
|
+
const jobId = historyMatch[1];
|
|
84
|
+
const events = ActivityStream.readAll(jobId);
|
|
85
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
86
|
+
res.end(JSON.stringify({ events }));
|
|
118
87
|
return;
|
|
119
88
|
}
|
|
120
89
|
|
|
121
|
-
|
|
122
|
-
res.
|
|
90
|
+
// All other /api/jobs/* endpoints removed — use /api/sessions/* instead
|
|
91
|
+
res.writeHead(410); // Gone
|
|
92
|
+
res.end(JSON.stringify({ error: 'Job monitoring endpoints removed. Use /api/sessions/:id/stream, /api/sessions/:id/abort, /api/sessions/:id/reply instead.' }));
|
|
123
93
|
}
|
|
124
94
|
|
|
125
95
|
/* ─── POST /api/jobs ─────────────────────── */
|
|
@@ -274,121 +244,6 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
274
244
|
jsonResponse(res, 200, { jobId: job.id, ...(sessionId && { sessionId }) });
|
|
275
245
|
}
|
|
276
246
|
|
|
277
|
-
/* ─── GET /api/jobs ──────────────────────── */
|
|
278
|
-
|
|
279
|
-
function handleListJobs(params: URLSearchParams, res: ServerResponse): void {
|
|
280
|
-
const status = params.get('status') as 'running' | 'done' | 'error' | null;
|
|
281
|
-
const roleId = params.get('roleId') ?? undefined;
|
|
282
|
-
|
|
283
|
-
const jobs = jobManager.listJobs({
|
|
284
|
-
status: status ?? undefined,
|
|
285
|
-
roleId,
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
jsonResponse(res, 200, { jobs });
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
/* ─── GET /api/jobs/:id ──────────────────── */
|
|
292
|
-
|
|
293
|
-
function handleGetJob(jobId: string, res: ServerResponse): void {
|
|
294
|
-
const info = jobManager.getJobInfo(jobId);
|
|
295
|
-
if (!info) {
|
|
296
|
-
jsonResponse(res, 404, { error: 'Job not found' });
|
|
297
|
-
return;
|
|
298
|
-
}
|
|
299
|
-
jsonResponse(res, 200, info);
|
|
300
|
-
}
|
|
301
|
-
|
|
302
|
-
/* ─── GET /api/jobs/:id/stream ───────────── */
|
|
303
|
-
|
|
304
|
-
function handleJobStream(jobId: string, fromSeq: number, req: IncomingMessage, res: ServerResponse): void {
|
|
305
|
-
const job = jobManager.getJob(jobId);
|
|
306
|
-
|
|
307
|
-
// Start SSE
|
|
308
|
-
startSSE(res);
|
|
309
|
-
|
|
310
|
-
// Replay historical events from file
|
|
311
|
-
const pastEvents = ActivityStream.readFrom(jobId, fromSeq);
|
|
312
|
-
for (const event of pastEvents) {
|
|
313
|
-
sendSSE(res, 'activity', event);
|
|
314
|
-
}
|
|
315
|
-
|
|
316
|
-
// If the job is finished (not running/awaiting), send end and close
|
|
317
|
-
if (!job || (job.status !== 'running' && job.status !== 'awaiting_input')) {
|
|
318
|
-
sendSSE(res, 'stream:end', { reason: job ? job.status : 'not-found' });
|
|
319
|
-
res.end();
|
|
320
|
-
return;
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
// Subscribe for live events
|
|
324
|
-
const subscriber = (event: ActivityEvent) => {
|
|
325
|
-
if (event.seq >= fromSeq) {
|
|
326
|
-
sendSSE(res, 'activity', event);
|
|
327
|
-
}
|
|
328
|
-
// Auto-close SSE when job ends or CEO replies (new stream takes over)
|
|
329
|
-
if (event.type === 'job:done' || event.type === 'job:error') {
|
|
330
|
-
sendSSE(res, 'stream:end', { reason: event.type === 'job:done' ? 'done' : 'error' });
|
|
331
|
-
res.end();
|
|
332
|
-
job.stream.unsubscribe(subscriber);
|
|
333
|
-
} else if (event.type === 'job:reply') {
|
|
334
|
-
// CEO replied → close this stream; frontend will connect to continuation job
|
|
335
|
-
sendSSE(res, 'stream:end', { reason: 'replied' });
|
|
336
|
-
res.end();
|
|
337
|
-
job.stream.unsubscribe(subscriber);
|
|
338
|
-
}
|
|
339
|
-
// awaiting_input keeps SSE open (sends event but doesn't close)
|
|
340
|
-
};
|
|
341
|
-
|
|
342
|
-
job.stream.subscribe(subscriber);
|
|
343
|
-
|
|
344
|
-
// Client disconnect → just unsubscribe (job keeps running)
|
|
345
|
-
req.on('close', () => {
|
|
346
|
-
job.stream.unsubscribe(subscriber);
|
|
347
|
-
});
|
|
348
|
-
}
|
|
349
|
-
|
|
350
|
-
/* ─── GET /api/jobs/:id/history ──────────── */
|
|
351
|
-
|
|
352
|
-
function handleJobHistory(jobId: string, res: ServerResponse): void {
|
|
353
|
-
if (!ActivityStream.exists(jobId)) {
|
|
354
|
-
jsonResponse(res, 404, { error: 'Job history not found' });
|
|
355
|
-
return;
|
|
356
|
-
}
|
|
357
|
-
const events = ActivityStream.readAll(jobId);
|
|
358
|
-
jsonResponse(res, 200, { events });
|
|
359
|
-
}
|
|
360
|
-
|
|
361
|
-
/* ─── DELETE /api/jobs/:id ───────────────── */
|
|
362
|
-
|
|
363
|
-
function handleAbortJob(jobId: string, res: ServerResponse): void {
|
|
364
|
-
const success = jobManager.abortJob(jobId);
|
|
365
|
-
if (!success) {
|
|
366
|
-
jsonResponse(res, 404, { error: 'Job not found or not running' });
|
|
367
|
-
return;
|
|
368
|
-
}
|
|
369
|
-
jsonResponse(res, 200, { ok: true });
|
|
370
|
-
}
|
|
371
|
-
|
|
372
|
-
/* ─── POST /api/jobs/:id/reply ──────────── */
|
|
373
|
-
|
|
374
|
-
function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: ServerResponse): void {
|
|
375
|
-
const message = body.message as string;
|
|
376
|
-
if (!message) {
|
|
377
|
-
jsonResponse(res, 400, { error: 'message is required' });
|
|
378
|
-
return;
|
|
379
|
-
}
|
|
380
|
-
|
|
381
|
-
const responderRole = body.responderRole as string | undefined;
|
|
382
|
-
|
|
383
|
-
const newJob = jobManager.replyToJob(jobId, message, responderRole);
|
|
384
|
-
if (!newJob) {
|
|
385
|
-
jsonResponse(res, 400, { error: 'Job not found or not in a replyable state' });
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
jsonResponse(res, 200, { jobId: newJob.id, roleId: newJob.roleId });
|
|
390
|
-
}
|
|
391
|
-
|
|
392
247
|
/* ─── POST /api/waves/save ──────────────── */
|
|
393
248
|
|
|
394
249
|
function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): void {
|
|
@@ -451,8 +306,22 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
451
306
|
if (!fs.existsSync(wavesDir)) {
|
|
452
307
|
fs.mkdirSync(wavesDir, { recursive: true });
|
|
453
308
|
}
|
|
454
|
-
|
|
455
|
-
|
|
309
|
+
|
|
310
|
+
// Dedup: if waveId matches an existing file, overwrite instead of creating new
|
|
311
|
+
let baseName: string;
|
|
312
|
+
if (waveId) {
|
|
313
|
+
const existing = fs.readdirSync(wavesDir).find(f => {
|
|
314
|
+
if (!f.endsWith('.json')) return false;
|
|
315
|
+
try {
|
|
316
|
+
const data = JSON.parse(fs.readFileSync(path.join(wavesDir, f), 'utf-8'));
|
|
317
|
+
return data.waveId === waveId || data.id === waveId;
|
|
318
|
+
} catch { return false; }
|
|
319
|
+
});
|
|
320
|
+
baseName = existing ? existing.replace('.json', '') : waveId;
|
|
321
|
+
} else {
|
|
322
|
+
const hhmmss = now.toTimeString().slice(0, 8).replace(/:/g, '');
|
|
323
|
+
baseName = `${dateStr.replace(/-/g, '')}-${hhmmss}-wave`;
|
|
324
|
+
}
|
|
456
325
|
const jsonPath = path.join(wavesDir, `${baseName}.json`);
|
|
457
326
|
|
|
458
327
|
const waveJson = {
|
|
@@ -467,6 +336,14 @@ function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
467
336
|
};
|
|
468
337
|
fs.writeFileSync(jsonPath, JSON.stringify(waveJson, null, 2), 'utf-8');
|
|
469
338
|
|
|
339
|
+
// EC-012: Wave completion bonus (participating roles × 500 coins)
|
|
340
|
+
const roleCount = rolesData.length;
|
|
341
|
+
if (roleCount > 0) {
|
|
342
|
+
try {
|
|
343
|
+
earnCoinsInternal(roleCount * 500, `Wave done: ${roleCount} roles`, `wave:${baseName}`);
|
|
344
|
+
} catch { /* non-critical */ }
|
|
345
|
+
}
|
|
346
|
+
|
|
470
347
|
jsonResponse(res, 200, { ok: true, path: `operations/waves/${baseName}.json` });
|
|
471
348
|
}
|
|
472
349
|
|
|
@@ -7,7 +7,11 @@ import {
|
|
|
7
7
|
deleteMany,
|
|
8
8
|
deleteEmpty,
|
|
9
9
|
updateSession,
|
|
10
|
+
addMessage,
|
|
11
|
+
type Message,
|
|
10
12
|
} from '../services/session-store.js';
|
|
13
|
+
import { jobManager } from '../services/job-manager.js';
|
|
14
|
+
import { ActivityStream, type ActivityEvent } from '../services/activity-stream.js';
|
|
11
15
|
|
|
12
16
|
export const sessionsRouter = Router();
|
|
13
17
|
|
|
@@ -73,3 +77,156 @@ sessionsRouter.delete('/:id', (req, res) => {
|
|
|
73
77
|
}
|
|
74
78
|
res.json({ ok: true });
|
|
75
79
|
});
|
|
80
|
+
|
|
81
|
+
/* ─── SCA-011: Session-based Job proxying ──── */
|
|
82
|
+
|
|
83
|
+
/** GET /api/sessions/:id/stream — SSE proxy to linked job's activity stream */
|
|
84
|
+
sessionsRouter.get('/:id/stream', (req, res) => {
|
|
85
|
+
const session = getSession(req.params.id);
|
|
86
|
+
if (!session) {
|
|
87
|
+
res.status(404).json({ error: 'Session not found' });
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const job = jobManager.getJobBySessionId(req.params.id);
|
|
92
|
+
const fromSeq = parseInt(req.query.from as string ?? '0', 10);
|
|
93
|
+
|
|
94
|
+
// Start SSE
|
|
95
|
+
res.writeHead(200, {
|
|
96
|
+
'Content-Type': 'text/event-stream',
|
|
97
|
+
'Cache-Control': 'no-cache',
|
|
98
|
+
'Connection': 'keep-alive',
|
|
99
|
+
'X-Accel-Buffering': 'no',
|
|
100
|
+
});
|
|
101
|
+
res.flushHeaders();
|
|
102
|
+
|
|
103
|
+
const sendEvent = (event: string, data: unknown) => {
|
|
104
|
+
if (res.destroyed || res.writableEnded) return;
|
|
105
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* ignore */ }
|
|
106
|
+
};
|
|
107
|
+
|
|
108
|
+
// If no job found, try to replay from the session's latest jobId in messages
|
|
109
|
+
const jobId = job?.id ?? session.messages.filter(m => m.jobId).pop()?.jobId;
|
|
110
|
+
|
|
111
|
+
if (jobId) {
|
|
112
|
+
// Replay historical events
|
|
113
|
+
const pastEvents = ActivityStream.readFrom(jobId, fromSeq);
|
|
114
|
+
for (const event of pastEvents) {
|
|
115
|
+
sendEvent('activity', event);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// If the job is finished or doesn't exist, end
|
|
120
|
+
if (!job || (job.status !== 'running' && job.status !== 'awaiting_input')) {
|
|
121
|
+
sendEvent('stream:end', { reason: job ? job.status : 'no-job' });
|
|
122
|
+
res.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// Subscribe for live events
|
|
127
|
+
const subscriber = (event: ActivityEvent) => {
|
|
128
|
+
if (event.seq >= fromSeq) {
|
|
129
|
+
sendEvent('activity', event);
|
|
130
|
+
}
|
|
131
|
+
if (event.type === 'job:done' || event.type === 'job:error') {
|
|
132
|
+
sendEvent('stream:end', { reason: event.type === 'job:done' ? 'done' : 'error' });
|
|
133
|
+
res.end();
|
|
134
|
+
job.stream.unsubscribe(subscriber);
|
|
135
|
+
} else if (event.type === 'job:reply') {
|
|
136
|
+
sendEvent('stream:end', { reason: 'replied' });
|
|
137
|
+
res.end();
|
|
138
|
+
job.stream.unsubscribe(subscriber);
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
|
|
142
|
+
job.stream.subscribe(subscriber);
|
|
143
|
+
|
|
144
|
+
// Heartbeat
|
|
145
|
+
const heartbeat = setInterval(() => {
|
|
146
|
+
if (res.destroyed || res.writableEnded) {
|
|
147
|
+
clearInterval(heartbeat);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
try { res.write(': heartbeat\n\n'); } catch { /* ignore */ }
|
|
151
|
+
}, 15_000);
|
|
152
|
+
|
|
153
|
+
req.on('close', () => {
|
|
154
|
+
clearInterval(heartbeat);
|
|
155
|
+
job.stream.unsubscribe(subscriber);
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
/** POST /api/sessions/:id/abort — abort linked job */
|
|
160
|
+
sessionsRouter.post('/:id/abort', (req, res) => {
|
|
161
|
+
const session = getSession(req.params.id);
|
|
162
|
+
if (!session) {
|
|
163
|
+
res.status(404).json({ error: 'Session not found' });
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const job = jobManager.getJobBySessionId(req.params.id);
|
|
168
|
+
if (!job) {
|
|
169
|
+
res.status(404).json({ error: 'No active job for this session' });
|
|
170
|
+
return;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
const success = jobManager.abortJob(job.id);
|
|
174
|
+
if (!success) {
|
|
175
|
+
res.status(400).json({ error: 'Job not running or already finished' });
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
res.json({ ok: true, jobId: job.id });
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
/** POST /api/sessions/:id/reply — reply to awaiting_input job via session */
|
|
183
|
+
sessionsRouter.post('/:id/reply', (req, res) => {
|
|
184
|
+
const session = getSession(req.params.id);
|
|
185
|
+
if (!session) {
|
|
186
|
+
res.status(404).json({ error: 'Session not found' });
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const { message, responderRole } = req.body;
|
|
191
|
+
if (!message) {
|
|
192
|
+
res.status(400).json({ error: 'message is required' });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const job = jobManager.getJobBySessionId(req.params.id);
|
|
197
|
+
if (!job) {
|
|
198
|
+
res.status(404).json({ error: 'No active job for this session' });
|
|
199
|
+
return;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// Add CEO reply message to session
|
|
203
|
+
const ceoMsg: Message = {
|
|
204
|
+
id: `msg-${Date.now()}-ceo-reply`,
|
|
205
|
+
from: 'ceo',
|
|
206
|
+
content: message,
|
|
207
|
+
type: 'conversation',
|
|
208
|
+
status: 'done',
|
|
209
|
+
timestamp: new Date().toISOString(),
|
|
210
|
+
};
|
|
211
|
+
addMessage(req.params.id, ceoMsg);
|
|
212
|
+
|
|
213
|
+
const newJob = jobManager.replyToJob(job.id, message, responderRole);
|
|
214
|
+
if (!newJob) {
|
|
215
|
+
res.status(400).json({ error: 'Job not in a replyable state' });
|
|
216
|
+
return;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// Add role message for the continuation job
|
|
220
|
+
const roleMsg: Message = {
|
|
221
|
+
id: `msg-${Date.now() + 1}-role-reply`,
|
|
222
|
+
from: 'role',
|
|
223
|
+
content: '',
|
|
224
|
+
type: 'conversation',
|
|
225
|
+
status: 'streaming',
|
|
226
|
+
timestamp: new Date().toISOString(),
|
|
227
|
+
jobId: newJob.id,
|
|
228
|
+
};
|
|
229
|
+
addMessage(req.params.id, roleMsg, true);
|
|
230
|
+
|
|
231
|
+
res.json({ ok: true, jobId: newJob.id, sessionId: req.params.id });
|
|
232
|
+
});
|