tycono 0.1.64 → 0.1.66
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/bin/tycono.ts +13 -4
- package/package.json +1 -1
- package/src/api/src/create-server.ts +5 -1
- package/src/api/src/engine/agent-loop.ts +17 -6
- package/src/api/src/engine/context-assembler.ts +156 -48
- package/src/api/src/engine/knowledge-gate.ts +335 -0
- package/src/api/src/engine/llm-adapter.ts +7 -1
- package/src/api/src/engine/runners/claude-cli.ts +98 -116
- package/src/api/src/engine/runners/types.ts +2 -0
- package/src/api/src/engine/tools/executor.ts +3 -5
- package/src/api/src/routes/active-sessions.ts +143 -0
- package/src/api/src/routes/coins.ts +137 -0
- package/src/api/src/routes/execute.ts +158 -48
- package/src/api/src/routes/knowledge.ts +30 -0
- package/src/api/src/routes/operations.ts +48 -11
- package/src/api/src/routes/sessions.ts +1 -1
- package/src/api/src/routes/setup.ts +68 -1
- package/src/api/src/routes/speech.ts +334 -142
- package/src/api/src/services/activity-stream.ts +1 -1
- package/src/api/src/services/job-manager.ts +185 -9
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/scaffold.ts +90 -0
- package/src/api/src/services/session-store.ts +75 -5
- package/src/web/dist/assets/index-BDLT2xew.js +109 -0
- package/src/web/dist/assets/index-LvS5V8aP.css +1 -0
- package/src/web/dist/assets/{preview-app-qIFqrb-y.js → preview-app-AJtyaM6L.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/templates/skills/_manifest.json +6 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/teams/agency.json +3 -3
- package/templates/teams/research.json +3 -3
- package/templates/teams/startup.json +3 -3
- package/src/web/dist/assets/index-B3dNhn76.js +0 -101
- package/src/web/dist/assets/index-C7IEX_o_.css +0 -1
|
@@ -8,6 +8,8 @@ import { setActivity, updateActivity, completeActivity } from './activity-tracke
|
|
|
8
8
|
import type { RunnerResult } from '../engine/runners/types.js';
|
|
9
9
|
import { estimateCost } from './pricing.js';
|
|
10
10
|
import { readConfig, getConversationLimits } from './company-config.js';
|
|
11
|
+
import { postKnowledgingCheck, type KnowledgeDebtItem } from '../engine/knowledge-gate.js';
|
|
12
|
+
import { getSession, updateMessage as updateSessionMessage, appendMessageEvent } from './session-store.js';
|
|
11
13
|
|
|
12
14
|
/* ─── Types ──────────────────────────────── */
|
|
13
15
|
|
|
@@ -29,6 +31,12 @@ export interface Job {
|
|
|
29
31
|
error?: string;
|
|
30
32
|
/** Which role should respond when status is awaiting_input */
|
|
31
33
|
targetRole?: string;
|
|
34
|
+
/** Selective dispatch scope — only these roles can be dispatched to */
|
|
35
|
+
targetRoles?: string[];
|
|
36
|
+
/** Knowledge debt items detected by Post-Knowledging check */
|
|
37
|
+
knowledgeDebt?: KnowledgeDebtItem[];
|
|
38
|
+
/** D-014: Session this job belongs to */
|
|
39
|
+
sessionId?: string;
|
|
32
40
|
}
|
|
33
41
|
|
|
34
42
|
export interface JobInfo {
|
|
@@ -42,6 +50,8 @@ export interface JobInfo {
|
|
|
42
50
|
createdAt: string;
|
|
43
51
|
/** Which role should respond when status is awaiting_input */
|
|
44
52
|
targetRole?: string;
|
|
53
|
+
/** Final output text (available when status is done) */
|
|
54
|
+
output?: string;
|
|
45
55
|
}
|
|
46
56
|
|
|
47
57
|
export interface StartJobParams {
|
|
@@ -54,6 +64,10 @@ export interface StartJobParams {
|
|
|
54
64
|
model?: string;
|
|
55
65
|
/** If true, this is a continuation from CEO reply — skip question detection */
|
|
56
66
|
isContinuation?: boolean;
|
|
67
|
+
/** Selective dispatch: only these roles are allowed as dispatch targets */
|
|
68
|
+
targetRoles?: string[];
|
|
69
|
+
/** D-014: Link this job to a session (internal tracking) */
|
|
70
|
+
sessionId?: string;
|
|
57
71
|
}
|
|
58
72
|
|
|
59
73
|
/* ─── Helpers ────────────────────────────── */
|
|
@@ -155,6 +169,8 @@ class JobManager {
|
|
|
155
169
|
parentJobId: params.parentJobId,
|
|
156
170
|
childJobIds: [],
|
|
157
171
|
createdAt: new Date().toISOString(),
|
|
172
|
+
targetRoles: params.targetRoles,
|
|
173
|
+
sessionId: params.sessionId,
|
|
158
174
|
};
|
|
159
175
|
|
|
160
176
|
this.jobs.set(jobId, job);
|
|
@@ -209,35 +225,71 @@ class JobManager {
|
|
|
209
225
|
sourceRole: params.sourceRole ?? 'ceo',
|
|
210
226
|
orgTree,
|
|
211
227
|
readOnly: params.readOnly,
|
|
212
|
-
maxTurns: limits.hardLimit,
|
|
228
|
+
maxTurns: limits.hardLimit,
|
|
213
229
|
model,
|
|
214
230
|
jobId,
|
|
215
231
|
teamStatus,
|
|
232
|
+
targetRoles: params.targetRoles,
|
|
216
233
|
},
|
|
217
234
|
{
|
|
218
235
|
onText: (text) => {
|
|
219
236
|
updateActivity(params.roleId, text);
|
|
220
237
|
stream.emit('text', params.roleId, { text });
|
|
238
|
+
// D-014: Update linked session message content
|
|
239
|
+
if (job.sessionId) {
|
|
240
|
+
this.updateSessionRoleMessage(job, text);
|
|
241
|
+
}
|
|
221
242
|
},
|
|
222
243
|
onThinking: (text) => {
|
|
223
244
|
stream.emit('thinking', params.roleId, { text });
|
|
245
|
+
// D-014 SCA-010: Embed thinking event in session message
|
|
246
|
+
if (job.sessionId) {
|
|
247
|
+
this.embedSessionEvent(job, 'thinking', { text: text.slice(0, 200) });
|
|
248
|
+
}
|
|
224
249
|
},
|
|
225
250
|
onToolUse: (name, input) => {
|
|
226
251
|
stream.emit('tool:start', params.roleId, {
|
|
227
252
|
name,
|
|
228
253
|
input: input ? summarizeInput(input) : undefined,
|
|
229
254
|
});
|
|
255
|
+
// D-014 SCA-010: Embed tool event in session message
|
|
256
|
+
if (job.sessionId) {
|
|
257
|
+
this.embedSessionEvent(job, 'tool:start', {
|
|
258
|
+
name,
|
|
259
|
+
input: input ? summarizeInput(input) : undefined,
|
|
260
|
+
});
|
|
261
|
+
}
|
|
230
262
|
},
|
|
231
263
|
onDispatch: (subRoleId, subTask) => {
|
|
264
|
+
// 2-layer defense: block dispatch to roles outside targetRoles scope
|
|
265
|
+
if (params.targetRoles && params.targetRoles.length > 0) {
|
|
266
|
+
if (!params.targetRoles.includes(subRoleId)) {
|
|
267
|
+
console.warn(`[JobManager] Dispatch blocked: ${params.roleId} → ${subRoleId} (not in targetRoles)`);
|
|
268
|
+
stream.emit('stderr', params.roleId, {
|
|
269
|
+
message: `Dispatch to ${subRoleId} blocked — not in active target scope for this wave.`,
|
|
270
|
+
});
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
}
|
|
232
274
|
// Create child job — startJob() auto-emits dispatch:start
|
|
233
275
|
// on parent stream when parentJobId is set.
|
|
234
|
-
|
|
276
|
+
// Propagate targetRoles to child jobs for cascading enforcement
|
|
277
|
+
const childJob = this.startJob({
|
|
235
278
|
type: 'assign',
|
|
236
279
|
roleId: subRoleId,
|
|
237
280
|
task: subTask,
|
|
238
281
|
sourceRole: params.roleId,
|
|
239
282
|
parentJobId: jobId,
|
|
283
|
+
targetRoles: params.targetRoles,
|
|
240
284
|
});
|
|
285
|
+
// D-014 SCA-010: Embed dispatch event in session message
|
|
286
|
+
if (job.sessionId) {
|
|
287
|
+
this.embedSessionEvent(job, 'dispatch:start', {
|
|
288
|
+
roleId: subRoleId,
|
|
289
|
+
task: subTask,
|
|
290
|
+
childJobId: childJob.id,
|
|
291
|
+
});
|
|
292
|
+
}
|
|
241
293
|
},
|
|
242
294
|
onConsult: (subRoleId, question) => {
|
|
243
295
|
// Create child job in read-only mode for consultation
|
|
@@ -348,9 +400,45 @@ class JobManager {
|
|
|
348
400
|
targetRole,
|
|
349
401
|
});
|
|
350
402
|
} else {
|
|
403
|
+
// ─── Post-Knowledging check ───
|
|
404
|
+
// Extract changed .md files from tool calls and check for knowledge debt
|
|
405
|
+
const changedMdFiles = result.toolCalls
|
|
406
|
+
.filter(tc => (tc.name === 'write_file' || tc.name === 'edit_file') && tc.input && typeof tc.input.path === 'string')
|
|
407
|
+
.map(tc => String(tc.input!.path))
|
|
408
|
+
.filter(p => p.endsWith('.md'));
|
|
409
|
+
|
|
410
|
+
if (changedMdFiles.length > 0) {
|
|
411
|
+
try {
|
|
412
|
+
const pkResult = postKnowledgingCheck(COMPANY_ROOT, changedMdFiles);
|
|
413
|
+
if (!pkResult.pass) {
|
|
414
|
+
job.knowledgeDebt = pkResult.debt;
|
|
415
|
+
console.log(
|
|
416
|
+
`[Post-K] Job ${jobId} (${params.roleId}): ${pkResult.debt.length} knowledge debt item(s)`,
|
|
417
|
+
);
|
|
418
|
+
for (const d of pkResult.debt) {
|
|
419
|
+
console.log(` [Post-K] ${d.type}: ${d.message}`);
|
|
420
|
+
}
|
|
421
|
+
// Include debt info in done event
|
|
422
|
+
(doneData as Record<string, unknown>).knowledgeDebt = pkResult.debt.map(d => ({
|
|
423
|
+
type: d.type,
|
|
424
|
+
file: d.file,
|
|
425
|
+
message: d.message,
|
|
426
|
+
}));
|
|
427
|
+
}
|
|
428
|
+
} catch (err) {
|
|
429
|
+
console.warn('[Post-K] Check failed:', err);
|
|
430
|
+
}
|
|
431
|
+
}
|
|
432
|
+
|
|
351
433
|
job.status = 'done';
|
|
352
434
|
completeActivity(params.roleId);
|
|
353
435
|
stream.emit('job:done', params.roleId, doneData);
|
|
436
|
+
// D-014: Update linked session message with final results
|
|
437
|
+
if (job.sessionId) {
|
|
438
|
+
this.finalizeSessionMessage(job, 'done', result);
|
|
439
|
+
}
|
|
440
|
+
// Cleanup orphaned child jobs (awaiting_input with no parent to respond)
|
|
441
|
+
this.cleanupOrphanedChildren(job.id);
|
|
354
442
|
}
|
|
355
443
|
})
|
|
356
444
|
.catch((err: Error) => {
|
|
@@ -374,11 +462,93 @@ class JobManager {
|
|
|
374
462
|
completeActivity(params.roleId);
|
|
375
463
|
|
|
376
464
|
stream.emit('job:error', params.roleId, { message: err.message });
|
|
465
|
+
// D-014: Mark linked session message as error
|
|
466
|
+
if (job.sessionId) {
|
|
467
|
+
this.finalizeSessionMessage(job, 'error');
|
|
468
|
+
}
|
|
377
469
|
});
|
|
378
470
|
|
|
379
471
|
return job;
|
|
380
472
|
}
|
|
381
473
|
|
|
474
|
+
/* ─── D-014: Session ↔ Job bridge ───────── */
|
|
475
|
+
|
|
476
|
+
/** Accumulated text content for session messages linked to jobs */
|
|
477
|
+
private sessionMsgContent = new Map<string, string>();
|
|
478
|
+
|
|
479
|
+
/** Update session role message content as text streams in */
|
|
480
|
+
private updateSessionRoleMessage(job: Job, text: string): void {
|
|
481
|
+
if (!job.sessionId) return;
|
|
482
|
+
const session = getSession(job.sessionId);
|
|
483
|
+
if (!session) return;
|
|
484
|
+
|
|
485
|
+
// Find the role message linked to this job
|
|
486
|
+
const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
|
|
487
|
+
if (!roleMsg) return;
|
|
488
|
+
|
|
489
|
+
const key = `${job.sessionId}:${roleMsg.id}`;
|
|
490
|
+
const current = (this.sessionMsgContent.get(key) ?? '') + text;
|
|
491
|
+
this.sessionMsgContent.set(key, current);
|
|
492
|
+
|
|
493
|
+
updateSessionMessage(job.sessionId, roleMsg.id, { content: current });
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
/** Embed an activity event into the session message linked to this job (SCA-010) */
|
|
497
|
+
private embedSessionEvent(job: Job, type: string, data: Record<string, unknown>): void {
|
|
498
|
+
if (!job.sessionId) return;
|
|
499
|
+
const session = getSession(job.sessionId);
|
|
500
|
+
if (!session) return;
|
|
501
|
+
|
|
502
|
+
const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
|
|
503
|
+
if (!roleMsg) return;
|
|
504
|
+
|
|
505
|
+
const event: ActivityEvent = {
|
|
506
|
+
seq: (roleMsg.events?.length ?? 0) + 1,
|
|
507
|
+
ts: new Date().toISOString(),
|
|
508
|
+
type: type as ActivityEvent['type'],
|
|
509
|
+
roleId: job.roleId,
|
|
510
|
+
data,
|
|
511
|
+
};
|
|
512
|
+
appendMessageEvent(job.sessionId, roleMsg.id, event);
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/** Finalize session message when job completes or errors */
|
|
516
|
+
private finalizeSessionMessage(job: Job, status: 'done' | 'error', result?: RunnerResult): void {
|
|
517
|
+
if (!job.sessionId) return;
|
|
518
|
+
const session = getSession(job.sessionId);
|
|
519
|
+
if (!session) return;
|
|
520
|
+
|
|
521
|
+
const roleMsg = session.messages.find(m => m.jobId === job.id && m.from === 'role');
|
|
522
|
+
if (!roleMsg) return;
|
|
523
|
+
|
|
524
|
+
const key = `${job.sessionId}:${roleMsg.id}`;
|
|
525
|
+
const finalContent = this.sessionMsgContent.get(key) ?? roleMsg.content;
|
|
526
|
+
this.sessionMsgContent.delete(key);
|
|
527
|
+
|
|
528
|
+
updateSessionMessage(job.sessionId, roleMsg.id, {
|
|
529
|
+
content: finalContent,
|
|
530
|
+
status,
|
|
531
|
+
...(result && {
|
|
532
|
+
turns: result.turns,
|
|
533
|
+
tokens: result.totalTokens,
|
|
534
|
+
}),
|
|
535
|
+
});
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/** Cleanup orphaned child jobs when parent completes */
|
|
539
|
+
private cleanupOrphanedChildren(parentJobId: string): void {
|
|
540
|
+
for (const job of this.jobs.values()) {
|
|
541
|
+
if (job.parentJobId === parentJobId && job.status === 'awaiting_input') {
|
|
542
|
+
job.status = 'done';
|
|
543
|
+
completeActivity(job.roleId);
|
|
544
|
+
job.stream.emit('job:done', job.roleId, {
|
|
545
|
+
output: '[Auto-closed] Parent job completed',
|
|
546
|
+
turns: 0,
|
|
547
|
+
});
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
|
|
382
552
|
/** Get a job by ID (in-memory or reconstruct from file) */
|
|
383
553
|
getJob(id: string): Job | undefined {
|
|
384
554
|
return this.jobs.get(id);
|
|
@@ -426,6 +596,7 @@ class JobManager {
|
|
|
426
596
|
childJobIds: job.childJobIds,
|
|
427
597
|
createdAt: job.createdAt,
|
|
428
598
|
targetRole: job.targetRole,
|
|
599
|
+
output: job.result?.output,
|
|
429
600
|
};
|
|
430
601
|
}
|
|
431
602
|
|
|
@@ -465,18 +636,19 @@ class JobManager {
|
|
|
465
636
|
return true;
|
|
466
637
|
}
|
|
467
638
|
|
|
468
|
-
/** Reply to an awaiting_input job → creates a continuation job */
|
|
639
|
+
/** Reply to an awaiting_input or done job → creates a continuation job */
|
|
469
640
|
replyToJob(id: string, response: string, responderRole?: string): Job | null {
|
|
470
641
|
const job = this.jobs.get(id);
|
|
471
|
-
if (!job || job.status !== 'awaiting_input') return null;
|
|
642
|
+
if (!job || (job.status !== 'awaiting_input' && job.status !== 'done')) return null;
|
|
472
643
|
|
|
644
|
+
const isFollowUp = job.status === 'done';
|
|
473
645
|
const effectiveResponder = responderRole ?? job.targetRole ?? 'ceo';
|
|
474
646
|
|
|
475
647
|
// Mark previous job as done (don't emit job:done — the stream stays open
|
|
476
648
|
// for the continuation job which will emit its own job:done when finished)
|
|
477
649
|
job.status = 'done';
|
|
478
|
-
completeActivity(job.roleId);
|
|
479
|
-
job.stream.emit('job:reply', job.roleId, { response, responderRole: effectiveResponder });
|
|
650
|
+
if (!isFollowUp) completeActivity(job.roleId);
|
|
651
|
+
job.stream.emit('job:reply', job.roleId, { response, responderRole: effectiveResponder, isFollowUp });
|
|
480
652
|
|
|
481
653
|
// Build continuation prompt with previous context
|
|
482
654
|
const prevOutput = job.result?.output ?? '';
|
|
@@ -486,16 +658,20 @@ class JobManager {
|
|
|
486
658
|
|
|
487
659
|
// Use the actual responder role name in the prompt
|
|
488
660
|
const responderLabel = effectiveResponder === 'ceo' ? 'CEO' : effectiveResponder.toUpperCase();
|
|
489
|
-
const continuationTask =
|
|
661
|
+
const continuationTask = isFollowUp
|
|
662
|
+
? `[CEO Follow-up Directive]\n${response}\n\n[Previous context — your earlier report follows]\n${contextSummary}`
|
|
663
|
+
: `[Continuation — previous output follows]\n${contextSummary}\n\n[${responderLabel} Response]\n${response}`;
|
|
490
664
|
|
|
491
|
-
// Create new job for same role
|
|
665
|
+
// Create new job for same role
|
|
666
|
+
// Follow-ups (from done state) should allow question detection since they're fresh directives
|
|
667
|
+
// Continuations (from awaiting_input) skip question detection to avoid infinite loops
|
|
492
668
|
const newJob = this.startJob({
|
|
493
669
|
type: job.type,
|
|
494
670
|
roleId: job.roleId,
|
|
495
671
|
task: continuationTask,
|
|
496
672
|
sourceRole: effectiveResponder,
|
|
497
673
|
parentJobId: job.id,
|
|
498
|
-
isContinuation:
|
|
674
|
+
isContinuation: !isFollowUp,
|
|
499
675
|
});
|
|
500
676
|
|
|
501
677
|
job.childJobIds.push(newJob.id);
|
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* port-registry.ts — Session port allocation and tracking
|
|
3
|
+
*
|
|
4
|
+
* Manages port assignments for parallel dev server sessions.
|
|
5
|
+
* Each job/session gets unique API + Vite ports to avoid conflicts.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'node:fs';
|
|
8
|
+
import path from 'node:path';
|
|
9
|
+
import net from 'node:net';
|
|
10
|
+
import { COMPANY_ROOT } from './file-reader.js';
|
|
11
|
+
|
|
12
|
+
/* ─── Types ──────────────────────────────── */
|
|
13
|
+
|
|
14
|
+
export interface PortAllocation {
|
|
15
|
+
api: number;
|
|
16
|
+
vite: number;
|
|
17
|
+
hmr?: number;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface SessionPort {
|
|
21
|
+
sessionId: string;
|
|
22
|
+
roleId: string;
|
|
23
|
+
task: string;
|
|
24
|
+
ports: PortAllocation;
|
|
25
|
+
worktreePath?: string;
|
|
26
|
+
pid?: number;
|
|
27
|
+
startedAt: string;
|
|
28
|
+
status: 'active' | 'idle' | 'dead';
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
interface RegistryFile {
|
|
32
|
+
sessions: SessionPort[];
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/* ─── Port Pools ─────────────────────────── */
|
|
36
|
+
|
|
37
|
+
const API_PORT_START = 3001;
|
|
38
|
+
const VITE_PORT_START = 5173;
|
|
39
|
+
const HMR_PORT_START = 24678;
|
|
40
|
+
const POOL_SIZE = 10;
|
|
41
|
+
|
|
42
|
+
/* ─── Helpers ────────────────────────────── */
|
|
43
|
+
|
|
44
|
+
function getRegistryPath(): string {
|
|
45
|
+
return path.join(COMPANY_ROOT, '.tycono', 'port-registry.json');
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function readRegistry(): RegistryFile {
|
|
49
|
+
const filePath = getRegistryPath();
|
|
50
|
+
try {
|
|
51
|
+
if (fs.existsSync(filePath)) {
|
|
52
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
53
|
+
}
|
|
54
|
+
} catch { /* ignore corrupt file */ }
|
|
55
|
+
return { sessions: [] };
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function writeRegistry(data: RegistryFile): void {
|
|
59
|
+
const filePath = getRegistryPath();
|
|
60
|
+
const dir = path.dirname(filePath);
|
|
61
|
+
if (!fs.existsSync(dir)) {
|
|
62
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
63
|
+
}
|
|
64
|
+
fs.writeFileSync(filePath, JSON.stringify(data, null, 2));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function isProcessAlive(pid: number): boolean {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
} catch {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function isPortAvailable(port: number): Promise<boolean> {
|
|
77
|
+
return new Promise((resolve) => {
|
|
78
|
+
const server = net.createServer();
|
|
79
|
+
server.once('error', () => resolve(false));
|
|
80
|
+
server.once('listening', () => {
|
|
81
|
+
server.close();
|
|
82
|
+
resolve(true);
|
|
83
|
+
});
|
|
84
|
+
server.listen(port, '127.0.0.1');
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/* ─── PortRegistry ───────────────────────── */
|
|
89
|
+
|
|
90
|
+
class PortRegistry {
|
|
91
|
+
/** Allocate ports for a new session */
|
|
92
|
+
async allocate(sessionId: string, roleId: string, task: string): Promise<PortAllocation> {
|
|
93
|
+
const registry = readRegistry();
|
|
94
|
+
const usedApi = new Set(registry.sessions.map(s => s.ports.api));
|
|
95
|
+
const usedVite = new Set(registry.sessions.map(s => s.ports.vite));
|
|
96
|
+
const usedHmr = new Set(registry.sessions.filter(s => s.ports.hmr).map(s => s.ports.hmr!));
|
|
97
|
+
|
|
98
|
+
// Find first available port in pool
|
|
99
|
+
let api = 0;
|
|
100
|
+
let vite = 0;
|
|
101
|
+
let hmr = 0;
|
|
102
|
+
|
|
103
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
104
|
+
const candidate = API_PORT_START + i;
|
|
105
|
+
if (!usedApi.has(candidate) && await isPortAvailable(candidate)) {
|
|
106
|
+
api = candidate;
|
|
107
|
+
break;
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
112
|
+
const candidate = VITE_PORT_START + i;
|
|
113
|
+
if (!usedVite.has(candidate) && await isPortAvailable(candidate)) {
|
|
114
|
+
vite = candidate;
|
|
115
|
+
break;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
for (let i = 0; i < POOL_SIZE; i++) {
|
|
120
|
+
const candidate = HMR_PORT_START + i;
|
|
121
|
+
if (!usedHmr.has(candidate) && await isPortAvailable(candidate)) {
|
|
122
|
+
hmr = candidate;
|
|
123
|
+
break;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Fallback: let OS pick
|
|
128
|
+
if (!api) api = 0;
|
|
129
|
+
if (!vite) vite = 0;
|
|
130
|
+
|
|
131
|
+
const ports: PortAllocation = { api, vite };
|
|
132
|
+
if (hmr) ports.hmr = hmr;
|
|
133
|
+
|
|
134
|
+
const session: SessionPort = {
|
|
135
|
+
sessionId,
|
|
136
|
+
roleId,
|
|
137
|
+
task: task.slice(0, 80),
|
|
138
|
+
ports,
|
|
139
|
+
startedAt: new Date().toISOString(),
|
|
140
|
+
status: 'active',
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
registry.sessions.push(session);
|
|
144
|
+
writeRegistry(registry);
|
|
145
|
+
|
|
146
|
+
return ports;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Release ports when a session ends */
|
|
150
|
+
release(sessionId: string): boolean {
|
|
151
|
+
const registry = readRegistry();
|
|
152
|
+
const before = registry.sessions.length;
|
|
153
|
+
registry.sessions = registry.sessions.filter(s => s.sessionId !== sessionId);
|
|
154
|
+
if (registry.sessions.length < before) {
|
|
155
|
+
writeRegistry(registry);
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
return false;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/** Update session info (e.g., set PID, worktree path) */
|
|
162
|
+
update(sessionId: string, patch: Partial<Pick<SessionPort, 'pid' | 'worktreePath' | 'status' | 'task'>>): boolean {
|
|
163
|
+
const registry = readRegistry();
|
|
164
|
+
const session = registry.sessions.find(s => s.sessionId === sessionId);
|
|
165
|
+
if (!session) return false;
|
|
166
|
+
|
|
167
|
+
if (patch.pid !== undefined) session.pid = patch.pid;
|
|
168
|
+
if (patch.worktreePath !== undefined) session.worktreePath = patch.worktreePath;
|
|
169
|
+
if (patch.status !== undefined) session.status = patch.status;
|
|
170
|
+
if (patch.task !== undefined) session.task = patch.task.slice(0, 80);
|
|
171
|
+
|
|
172
|
+
writeRegistry(registry);
|
|
173
|
+
return true;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** Get all sessions */
|
|
177
|
+
getAll(): SessionPort[] {
|
|
178
|
+
return readRegistry().sessions;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Get a specific session */
|
|
182
|
+
get(sessionId: string): SessionPort | null {
|
|
183
|
+
return readRegistry().sessions.find(s => s.sessionId === sessionId) ?? null;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/** Detect and clean up dead sessions (PID gone) */
|
|
187
|
+
cleanup(): { cleaned: SessionPort[]; remaining: SessionPort[] } {
|
|
188
|
+
const registry = readRegistry();
|
|
189
|
+
const cleaned: SessionPort[] = [];
|
|
190
|
+
const remaining: SessionPort[] = [];
|
|
191
|
+
|
|
192
|
+
for (const session of registry.sessions) {
|
|
193
|
+
if (session.pid && !isProcessAlive(session.pid)) {
|
|
194
|
+
session.status = 'dead';
|
|
195
|
+
cleaned.push(session);
|
|
196
|
+
} else {
|
|
197
|
+
remaining.push(session);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (cleaned.length > 0) {
|
|
202
|
+
registry.sessions = remaining;
|
|
203
|
+
writeRegistry(registry);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
return { cleaned, remaining };
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** Get summary stats */
|
|
210
|
+
getSummary(): { active: number; totalPorts: number } {
|
|
211
|
+
const sessions = this.getAll();
|
|
212
|
+
const active = sessions.filter(s => s.status === 'active').length;
|
|
213
|
+
return {
|
|
214
|
+
active,
|
|
215
|
+
totalPorts: active * 2, // api + vite per session
|
|
216
|
+
};
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
/* ─── Export singleton ───────────────────── */
|
|
221
|
+
|
|
222
|
+
export const portRegistry = new PortRegistry();
|
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
import fs from 'node:fs';
|
|
7
7
|
import path from 'node:path';
|
|
8
8
|
import { fileURLToPath } from 'node:url';
|
|
9
|
+
import { execSync } from 'node:child_process';
|
|
9
10
|
import { writeConfig } from './company-config.js';
|
|
10
11
|
import type { CompanyConfig } from './company-config.js';
|
|
11
12
|
|
|
@@ -61,6 +62,12 @@ export function getAvailableTeams(): string[] {
|
|
|
61
62
|
.map(f => f.replace('.json', ''));
|
|
62
63
|
}
|
|
63
64
|
|
|
65
|
+
export interface SkillToolDef {
|
|
66
|
+
package: string;
|
|
67
|
+
binary: string;
|
|
68
|
+
installCmd: string;
|
|
69
|
+
}
|
|
70
|
+
|
|
64
71
|
export interface SkillMeta {
|
|
65
72
|
id: string;
|
|
66
73
|
name: string;
|
|
@@ -72,6 +79,7 @@ export interface SkillMeta {
|
|
|
72
79
|
compatibleRoles: string[];
|
|
73
80
|
dependencies: string[];
|
|
74
81
|
files: string[];
|
|
82
|
+
tools?: SkillToolDef[];
|
|
75
83
|
}
|
|
76
84
|
|
|
77
85
|
/**
|
|
@@ -94,6 +102,88 @@ export function getAvailableSkills(): SkillMeta[] {
|
|
|
94
102
|
return skills;
|
|
95
103
|
}
|
|
96
104
|
|
|
105
|
+
/**
|
|
106
|
+
* Check if a CLI binary is available on the system
|
|
107
|
+
*/
|
|
108
|
+
function isBinaryInstalled(binary: string): boolean {
|
|
109
|
+
try {
|
|
110
|
+
execSync(`which ${binary}`, { stdio: 'ignore', timeout: 5000 });
|
|
111
|
+
return true;
|
|
112
|
+
} catch {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Collect all tools required by a set of skills
|
|
119
|
+
*/
|
|
120
|
+
export function getRequiredTools(skillIds: string[]): Array<SkillToolDef & { skillId: string; installed: boolean }> {
|
|
121
|
+
const tools: Array<SkillToolDef & { skillId: string; installed: boolean }> = [];
|
|
122
|
+
const seen = new Set<string>();
|
|
123
|
+
|
|
124
|
+
for (const skillId of skillIds) {
|
|
125
|
+
const metaPath = path.join(TEMPLATES_DIR, 'skills', skillId, 'meta.json');
|
|
126
|
+
if (!fs.existsSync(metaPath)) continue;
|
|
127
|
+
|
|
128
|
+
const meta: SkillMeta = JSON.parse(fs.readFileSync(metaPath, 'utf-8'));
|
|
129
|
+
if (!meta.tools?.length) continue;
|
|
130
|
+
|
|
131
|
+
for (const tool of meta.tools) {
|
|
132
|
+
if (seen.has(tool.package)) continue;
|
|
133
|
+
seen.add(tool.package);
|
|
134
|
+
tools.push({
|
|
135
|
+
...tool,
|
|
136
|
+
skillId,
|
|
137
|
+
installed: isBinaryInstalled(tool.binary),
|
|
138
|
+
});
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
return tools;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
export interface ToolInstallCallbacks {
|
|
146
|
+
onChecking?: (tool: string) => void;
|
|
147
|
+
onInstalling?: (tool: string) => void;
|
|
148
|
+
onInstalled?: (tool: string) => void;
|
|
149
|
+
onSkipped?: (tool: string, reason: string) => void;
|
|
150
|
+
onError?: (tool: string, error: string) => void;
|
|
151
|
+
onDone?: (stats: { installed: number; skipped: number; failed: number }) => void;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Install CLI tools required by skills
|
|
156
|
+
*/
|
|
157
|
+
export function installSkillTools(skillIds: string[], callbacks?: ToolInstallCallbacks): void {
|
|
158
|
+
const tools = getRequiredTools(skillIds);
|
|
159
|
+
let installed = 0;
|
|
160
|
+
let skipped = 0;
|
|
161
|
+
let failed = 0;
|
|
162
|
+
|
|
163
|
+
for (const tool of tools) {
|
|
164
|
+
callbacks?.onChecking?.(tool.package);
|
|
165
|
+
|
|
166
|
+
if (tool.installed) {
|
|
167
|
+
callbacks?.onSkipped?.(tool.package, 'already installed');
|
|
168
|
+
skipped++;
|
|
169
|
+
continue;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
callbacks?.onInstalling?.(tool.package);
|
|
173
|
+
try {
|
|
174
|
+
execSync(tool.installCmd, { stdio: 'ignore', timeout: 120000 });
|
|
175
|
+
callbacks?.onInstalled?.(tool.package);
|
|
176
|
+
installed++;
|
|
177
|
+
} catch (err) {
|
|
178
|
+
const msg = err instanceof Error ? err.message : 'install failed';
|
|
179
|
+
callbacks?.onError?.(tool.package, msg);
|
|
180
|
+
failed++;
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
callbacks?.onDone?.({ installed, skipped, failed });
|
|
185
|
+
}
|
|
186
|
+
|
|
97
187
|
function renderTemplate(template: string, vars: Record<string, string>): string {
|
|
98
188
|
let result = template;
|
|
99
189
|
for (const [key, value] of Object.entries(vars)) {
|