tycono 0.1.70 → 0.1.71

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.70",
3
+ "version": "0.1.71",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -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 ?? 'auto';
161
- if (lang !== 'auto') {
162
- const langNames: Record<string, string> = { en: 'English', ko: 'Korean', ja: 'Japanese' };
163
- sections.push(`# Language\n\nAlways respond in **${langNames[lang] ?? lang}**. All output — reports, analysis, code comments, status updates — must be in ${langNames[lang] ?? lang}.`);
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
- // Strip query string for matching
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 — list 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/historyinternal only (used by engine Python sub-processes)
105
81
  const historyMatch = path.match(/^\/api\/jobs\/([^/]+)\/history$/);
106
- if (historyMatch && method === 'GET') {
107
- handleJobHistory(historyMatch[1], res);
108
- return;
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
- res.writeHead(404);
123
- res.end(JSON.stringify({ error: 'Not found' }));
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
+ });