tycono 0.1.70 → 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/execute.ts +11 -157
- package/src/api/src/routes/sessions.ts +157 -0
- package/src/api/src/services/job-manager.ts +128 -31
- package/src/api/src/services/preferences.ts +11 -0
- package/src/api/src/services/scaffold.ts +4 -0
- 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-B1XJLGLG.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-CsRhaCla.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 ───────────────────────────────── */
|
|
@@ -69,58 +69,27 @@ export function handleExecRequest(req: IncomingMessage, res: ServerResponse): vo
|
|
|
69
69
|
═══════════════════════════════════════════════ */
|
|
70
70
|
|
|
71
71
|
function handleJobsRequest(url: string, method: string, req: IncomingMessage, res: ServerResponse): void {
|
|
72
|
-
|
|
73
|
-
const [path, queryString] = url.split('?');
|
|
72
|
+
const [path] = url.split('?');
|
|
74
73
|
|
|
75
|
-
// POST /api/jobs — start a new job
|
|
74
|
+
// POST /api/jobs — start a new job (creates session + job)
|
|
76
75
|
if (method === 'POST' && path === '/api/jobs') {
|
|
77
76
|
readBody(req).then((body) => handleStartJob(body, res));
|
|
78
77
|
return;
|
|
79
78
|
}
|
|
80
79
|
|
|
81
|
-
// GET /api/jobs —
|
|
82
|
-
if (method === 'GET' && path === '/api/jobs') {
|
|
83
|
-
const params = new URLSearchParams(queryString ?? '');
|
|
84
|
-
handleListJobs(params, res);
|
|
85
|
-
return;
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Match /api/jobs/:id/stream
|
|
89
|
-
const streamMatch = path.match(/^\/api\/jobs\/([^/]+)\/stream$/);
|
|
90
|
-
if (streamMatch && method === 'GET') {
|
|
91
|
-
const params = new URLSearchParams(queryString ?? '');
|
|
92
|
-
const fromSeq = parseInt(params.get('from') ?? '0', 10);
|
|
93
|
-
handleJobStream(streamMatch[1], fromSeq, req, res);
|
|
94
|
-
return;
|
|
95
|
-
}
|
|
96
|
-
|
|
97
|
-
// Match /api/jobs/:id/reply
|
|
98
|
-
const replyMatch = path.match(/^\/api\/jobs\/([^/]+)\/reply$/);
|
|
99
|
-
if (replyMatch && method === 'POST') {
|
|
100
|
-
readBody(req).then((body) => handleReplyToJob(replyMatch[1], body, res));
|
|
101
|
-
return;
|
|
102
|
-
}
|
|
103
|
-
|
|
104
|
-
// Match /api/jobs/:id/history
|
|
80
|
+
// GET /api/jobs/:id/history — internal only (used by engine Python sub-processes)
|
|
105
81
|
const historyMatch = path.match(/^\/api\/jobs\/([^/]+)\/history$/);
|
|
106
|
-
if (
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
// Match /api/jobs/:id
|
|
112
|
-
const idMatch = path.match(/^\/api\/jobs\/([^/]+)$/);
|
|
113
|
-
if (idMatch && method === 'GET') {
|
|
114
|
-
handleGetJob(idMatch[1], res);
|
|
115
|
-
return;
|
|
116
|
-
}
|
|
117
|
-
if (idMatch && method === 'DELETE') {
|
|
118
|
-
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 }));
|
|
119
87
|
return;
|
|
120
88
|
}
|
|
121
89
|
|
|
122
|
-
|
|
123
|
-
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.' }));
|
|
124
93
|
}
|
|
125
94
|
|
|
126
95
|
/* ─── POST /api/jobs ─────────────────────── */
|
|
@@ -275,121 +244,6 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
|
|
|
275
244
|
jsonResponse(res, 200, { jobId: job.id, ...(sessionId && { sessionId }) });
|
|
276
245
|
}
|
|
277
246
|
|
|
278
|
-
/* ─── GET /api/jobs ──────────────────────── */
|
|
279
|
-
|
|
280
|
-
function handleListJobs(params: URLSearchParams, res: ServerResponse): void {
|
|
281
|
-
const status = params.get('status') as 'running' | 'done' | 'error' | null;
|
|
282
|
-
const roleId = params.get('roleId') ?? undefined;
|
|
283
|
-
|
|
284
|
-
const jobs = jobManager.listJobs({
|
|
285
|
-
status: status ?? undefined,
|
|
286
|
-
roleId,
|
|
287
|
-
});
|
|
288
|
-
|
|
289
|
-
jsonResponse(res, 200, { jobs });
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
/* ─── GET /api/jobs/:id ──────────────────── */
|
|
293
|
-
|
|
294
|
-
function handleGetJob(jobId: string, res: ServerResponse): void {
|
|
295
|
-
const info = jobManager.getJobInfo(jobId);
|
|
296
|
-
if (!info) {
|
|
297
|
-
jsonResponse(res, 404, { error: 'Job not found' });
|
|
298
|
-
return;
|
|
299
|
-
}
|
|
300
|
-
jsonResponse(res, 200, info);
|
|
301
|
-
}
|
|
302
|
-
|
|
303
|
-
/* ─── GET /api/jobs/:id/stream ───────────── */
|
|
304
|
-
|
|
305
|
-
function handleJobStream(jobId: string, fromSeq: number, req: IncomingMessage, res: ServerResponse): void {
|
|
306
|
-
const job = jobManager.getJob(jobId);
|
|
307
|
-
|
|
308
|
-
// Start SSE
|
|
309
|
-
startSSE(res);
|
|
310
|
-
|
|
311
|
-
// Replay historical events from file
|
|
312
|
-
const pastEvents = ActivityStream.readFrom(jobId, fromSeq);
|
|
313
|
-
for (const event of pastEvents) {
|
|
314
|
-
sendSSE(res, 'activity', event);
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// If the job is finished (not running/awaiting), send end and close
|
|
318
|
-
if (!job || (job.status !== 'running' && job.status !== 'awaiting_input')) {
|
|
319
|
-
sendSSE(res, 'stream:end', { reason: job ? job.status : 'not-found' });
|
|
320
|
-
res.end();
|
|
321
|
-
return;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Subscribe for live events
|
|
325
|
-
const subscriber = (event: ActivityEvent) => {
|
|
326
|
-
if (event.seq >= fromSeq) {
|
|
327
|
-
sendSSE(res, 'activity', event);
|
|
328
|
-
}
|
|
329
|
-
// Auto-close SSE when job ends or CEO replies (new stream takes over)
|
|
330
|
-
if (event.type === 'job:done' || event.type === 'job:error') {
|
|
331
|
-
sendSSE(res, 'stream:end', { reason: event.type === 'job:done' ? 'done' : 'error' });
|
|
332
|
-
res.end();
|
|
333
|
-
job.stream.unsubscribe(subscriber);
|
|
334
|
-
} else if (event.type === 'job:reply') {
|
|
335
|
-
// CEO replied → close this stream; frontend will connect to continuation job
|
|
336
|
-
sendSSE(res, 'stream:end', { reason: 'replied' });
|
|
337
|
-
res.end();
|
|
338
|
-
job.stream.unsubscribe(subscriber);
|
|
339
|
-
}
|
|
340
|
-
// awaiting_input keeps SSE open (sends event but doesn't close)
|
|
341
|
-
};
|
|
342
|
-
|
|
343
|
-
job.stream.subscribe(subscriber);
|
|
344
|
-
|
|
345
|
-
// Client disconnect → just unsubscribe (job keeps running)
|
|
346
|
-
req.on('close', () => {
|
|
347
|
-
job.stream.unsubscribe(subscriber);
|
|
348
|
-
});
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
/* ─── GET /api/jobs/:id/history ──────────── */
|
|
352
|
-
|
|
353
|
-
function handleJobHistory(jobId: string, res: ServerResponse): void {
|
|
354
|
-
if (!ActivityStream.exists(jobId)) {
|
|
355
|
-
jsonResponse(res, 404, { error: 'Job history not found' });
|
|
356
|
-
return;
|
|
357
|
-
}
|
|
358
|
-
const events = ActivityStream.readAll(jobId);
|
|
359
|
-
jsonResponse(res, 200, { events });
|
|
360
|
-
}
|
|
361
|
-
|
|
362
|
-
/* ─── DELETE /api/jobs/:id ───────────────── */
|
|
363
|
-
|
|
364
|
-
function handleAbortJob(jobId: string, res: ServerResponse): void {
|
|
365
|
-
const success = jobManager.abortJob(jobId);
|
|
366
|
-
if (!success) {
|
|
367
|
-
jsonResponse(res, 404, { error: 'Job not found or not running' });
|
|
368
|
-
return;
|
|
369
|
-
}
|
|
370
|
-
jsonResponse(res, 200, { ok: true });
|
|
371
|
-
}
|
|
372
|
-
|
|
373
|
-
/* ─── POST /api/jobs/:id/reply ──────────── */
|
|
374
|
-
|
|
375
|
-
function handleReplyToJob(jobId: string, body: Record<string, unknown>, res: ServerResponse): void {
|
|
376
|
-
const message = body.message as string;
|
|
377
|
-
if (!message) {
|
|
378
|
-
jsonResponse(res, 400, { error: 'message is required' });
|
|
379
|
-
return;
|
|
380
|
-
}
|
|
381
|
-
|
|
382
|
-
const responderRole = body.responderRole as string | undefined;
|
|
383
|
-
|
|
384
|
-
const newJob = jobManager.replyToJob(jobId, message, responderRole);
|
|
385
|
-
if (!newJob) {
|
|
386
|
-
jsonResponse(res, 400, { error: 'Job not found or not in a replyable state' });
|
|
387
|
-
return;
|
|
388
|
-
}
|
|
389
|
-
|
|
390
|
-
jsonResponse(res, 200, { jobId: newJob.id, roleId: newJob.roleId });
|
|
391
|
-
}
|
|
392
|
-
|
|
393
247
|
/* ─── POST /api/waves/save ──────────────── */
|
|
394
248
|
|
|
395
249
|
function handleSaveWave(body: Record<string, unknown>, res: ServerResponse): void {
|
|
@@ -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
|
+
});
|