tycono-server 0.1.0-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/bin/cli.js +35 -0
- package/bin/server.ts +160 -0
- package/package.json +50 -0
- package/src/api/package.json +31 -0
- package/src/api/src/create-app.ts +90 -0
- package/src/api/src/create-server.ts +251 -0
- package/src/api/src/engine/agent-loop.ts +738 -0
- package/src/api/src/engine/authority-validator.ts +149 -0
- package/src/api/src/engine/context-assembler.ts +912 -0
- package/src/api/src/engine/index.ts +27 -0
- package/src/api/src/engine/knowledge-gate.ts +365 -0
- package/src/api/src/engine/llm-adapter.ts +304 -0
- package/src/api/src/engine/org-tree.ts +270 -0
- package/src/api/src/engine/role-lifecycle.ts +369 -0
- package/src/api/src/engine/runners/claude-cli.ts +796 -0
- package/src/api/src/engine/runners/direct-api.ts +66 -0
- package/src/api/src/engine/runners/index.ts +30 -0
- package/src/api/src/engine/runners/types.ts +95 -0
- package/src/api/src/engine/skill-template.ts +134 -0
- package/src/api/src/engine/tools/definitions.ts +201 -0
- package/src/api/src/engine/tools/executor.ts +611 -0
- package/src/api/src/routes/active-sessions.ts +134 -0
- package/src/api/src/routes/coins.ts +153 -0
- package/src/api/src/routes/company.ts +57 -0
- package/src/api/src/routes/cost.ts +141 -0
- package/src/api/src/routes/engine.ts +220 -0
- package/src/api/src/routes/execute.ts +1075 -0
- package/src/api/src/routes/git.ts +211 -0
- package/src/api/src/routes/knowledge.ts +378 -0
- package/src/api/src/routes/operations.ts +309 -0
- package/src/api/src/routes/preferences.ts +63 -0
- package/src/api/src/routes/presets.ts +123 -0
- package/src/api/src/routes/projects.ts +82 -0
- package/src/api/src/routes/quests.ts +41 -0
- package/src/api/src/routes/roles.ts +112 -0
- package/src/api/src/routes/save.ts +152 -0
- package/src/api/src/routes/sessions.ts +288 -0
- package/src/api/src/routes/setup.ts +437 -0
- package/src/api/src/routes/skills.ts +357 -0
- package/src/api/src/routes/speech.ts +959 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/routes/sync.ts +165 -0
- package/src/api/src/server.ts +59 -0
- package/src/api/src/services/activity-stream.ts +184 -0
- package/src/api/src/services/activity-tracker.ts +115 -0
- package/src/api/src/services/claude-md-manager.ts +94 -0
- package/src/api/src/services/company-config.ts +115 -0
- package/src/api/src/services/database.ts +77 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +1036 -0
- package/src/api/src/services/file-reader.ts +77 -0
- package/src/api/src/services/git-save.ts +614 -0
- package/src/api/src/services/job-manager.ts +16 -0
- package/src/api/src/services/knowledge-importer.ts +466 -0
- package/src/api/src/services/markdown-parser.ts +173 -0
- package/src/api/src/services/port-registry.ts +222 -0
- package/src/api/src/services/preferences.ts +150 -0
- package/src/api/src/services/preset-loader.ts +149 -0
- package/src/api/src/services/pricing.ts +34 -0
- package/src/api/src/services/scaffold.ts +546 -0
- package/src/api/src/services/session-store.ts +340 -0
- package/src/api/src/services/supervisor-heartbeat.ts +897 -0
- package/src/api/src/services/team-recommender.ts +382 -0
- package/src/api/src/services/token-ledger.ts +127 -0
- package/src/api/src/services/wave-messages.ts +194 -0
- package/src/api/src/services/wave-multiplexer.ts +356 -0
- package/src/api/src/services/wave-tracker.ts +359 -0
- package/src/api/src/utils/role-level.ts +31 -0
- package/src/core/scaffolder.ts +620 -0
- package/src/shared/types.ts +224 -0
- package/templates/CLAUDE.md.tmpl +239 -0
- package/templates/company.md.tmpl +17 -0
- package/templates/gitignore.tmpl +28 -0
- package/templates/roles.md.tmpl +8 -0
- package/templates/skills/_manifest.json +23 -0
- package/templates/skills/agent-browser/SKILL.md +159 -0
- package/templates/skills/agent-browser/meta.json +19 -0
- package/templates/skills/akb-linter/SKILL.md +125 -0
- package/templates/skills/akb-linter/meta.json +12 -0
- package/templates/skills/knowledge-gate/SKILL.md +120 -0
- package/templates/skills/knowledge-gate/meta.json +12 -0
- package/templates/teams/agency.json +58 -0
- package/templates/teams/research.json +58 -0
- package/templates/teams/startup.json +58 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Supervision API — Long-poll watch + peer session discovery (SV-13)
|
|
3
|
+
*
|
|
4
|
+
* GET /api/supervision/watch?sessions=ses-001,ses-002&duration=120&alertOn=msg:done
|
|
5
|
+
* → Long-poll: blocks for duration seconds → returns JSON digest
|
|
6
|
+
*
|
|
7
|
+
* GET /api/supervision/peers?waveId=xxx&roleId=cto
|
|
8
|
+
* → Returns peer C-Level sessions in the same wave
|
|
9
|
+
*/
|
|
10
|
+
import { Router, type Request, type Response } from 'express';
|
|
11
|
+
import { ActivityStream } from '../services/activity-stream.js';
|
|
12
|
+
import { digest, quietDigest } from '../services/digest-engine.js';
|
|
13
|
+
import { executionManager } from '../services/execution-manager.js';
|
|
14
|
+
import type { ActivityEvent } from '../../../shared/types.js';
|
|
15
|
+
|
|
16
|
+
export const supervisionRouter = Router();
|
|
17
|
+
|
|
18
|
+
/* ─── GET /watch — Long-poll supervision digest ─── */
|
|
19
|
+
|
|
20
|
+
supervisionRouter.get('/watch', async (req: Request, res: Response) => {
|
|
21
|
+
const sessionsParam = req.query.sessions as string | undefined;
|
|
22
|
+
if (!sessionsParam) {
|
|
23
|
+
res.status(400).json({ error: 'sessions query parameter is required (comma-separated session IDs)' });
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const sessionIds = sessionsParam.split(',').filter(Boolean);
|
|
28
|
+
if (sessionIds.length === 0) {
|
|
29
|
+
res.status(400).json({ error: 'At least one session ID is required' });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const durationSec = Math.min(Math.max(Number(req.query.duration) || 120, 5), 300);
|
|
34
|
+
const alertOnParam = req.query.alertOn as string | undefined;
|
|
35
|
+
const alertOn = alertOnParam ? alertOnParam.split(',') : ['msg:done', 'msg:error'];
|
|
36
|
+
const alertSet = new Set(alertOn);
|
|
37
|
+
|
|
38
|
+
// Record start checkpoints
|
|
39
|
+
const startCheckpoints = new Map<string, number>();
|
|
40
|
+
for (const sid of sessionIds) {
|
|
41
|
+
const events = ActivityStream.readAll(sid);
|
|
42
|
+
startCheckpoints.set(sid, events.length > 0 ? events[events.length - 1].seq + 1 : 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Set up event collection
|
|
46
|
+
const collectedEvents = new Map<string, ActivityEvent[]>();
|
|
47
|
+
for (const sid of sessionIds) {
|
|
48
|
+
collectedEvents.set(sid, []);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
let earlyReturn = false;
|
|
52
|
+
const unsubscribers: Array<() => void> = [];
|
|
53
|
+
|
|
54
|
+
for (const sid of sessionIds) {
|
|
55
|
+
const stream = ActivityStream.getOrCreate(sid, 'unknown');
|
|
56
|
+
const handler = (event: ActivityEvent) => {
|
|
57
|
+
const events = collectedEvents.get(sid);
|
|
58
|
+
if (events) events.push(event);
|
|
59
|
+
if (alertSet.has(event.type)) {
|
|
60
|
+
earlyReturn = true;
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
stream.subscribe(handler);
|
|
64
|
+
unsubscribers.push(() => stream.unsubscribe(handler));
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Wait for duration or early return
|
|
68
|
+
await new Promise<void>((resolve) => {
|
|
69
|
+
const timeout = setTimeout(resolve, durationSec * 1000);
|
|
70
|
+
const checkInterval = setInterval(() => {
|
|
71
|
+
if (earlyReturn) {
|
|
72
|
+
clearTimeout(timeout);
|
|
73
|
+
clearInterval(checkInterval);
|
|
74
|
+
resolve();
|
|
75
|
+
}
|
|
76
|
+
}, 500);
|
|
77
|
+
setTimeout(() => { clearInterval(checkInterval); }, durationSec * 1000 + 100);
|
|
78
|
+
});
|
|
79
|
+
|
|
80
|
+
// Unsubscribe all
|
|
81
|
+
for (const unsub of unsubscribers) unsub();
|
|
82
|
+
|
|
83
|
+
// Fallback: read from JSONL for sessions with no live events
|
|
84
|
+
for (const sid of sessionIds) {
|
|
85
|
+
const fromSeq = startCheckpoints.get(sid) ?? 0;
|
|
86
|
+
const liveEvents = collectedEvents.get(sid) ?? [];
|
|
87
|
+
if (liveEvents.length === 0) {
|
|
88
|
+
const fileEvents = ActivityStream.readFrom(sid, fromSeq);
|
|
89
|
+
collectedEvents.set(sid, fileEvents);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const result = digest(collectedEvents);
|
|
94
|
+
|
|
95
|
+
res.json({
|
|
96
|
+
text: result.text,
|
|
97
|
+
significanceScore: result.significanceScore,
|
|
98
|
+
anomalies: result.anomalies,
|
|
99
|
+
checkpoints: Object.fromEntries(result.checkpoints),
|
|
100
|
+
eventCount: result.eventCount,
|
|
101
|
+
errorCount: result.errorCount,
|
|
102
|
+
earlyReturn,
|
|
103
|
+
});
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
/* ─── GET /peers — Peer C-Level session discovery ─── */
|
|
107
|
+
|
|
108
|
+
supervisionRouter.get('/peers', (req: Request, res: Response) => {
|
|
109
|
+
const waveId = req.query.waveId as string | undefined;
|
|
110
|
+
const roleId = req.query.roleId as string | undefined;
|
|
111
|
+
|
|
112
|
+
if (!waveId || !roleId) {
|
|
113
|
+
res.status(400).json({ error: 'waveId and roleId are required' });
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Find all active executions in the same wave that are C-Level
|
|
118
|
+
const allExecs = executionManager.listExecutions({ active: true });
|
|
119
|
+
const peers = allExecs.filter(exec => {
|
|
120
|
+
if (exec.roleId === roleId) return false; // Exclude self
|
|
121
|
+
// Check if this execution belongs to the same wave
|
|
122
|
+
// Wave membership is tracked via session store
|
|
123
|
+
return true; // For now, return all active C-Level sessions
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
res.json({
|
|
127
|
+
waveId,
|
|
128
|
+
roleId,
|
|
129
|
+
peers: peers.map(p => ({
|
|
130
|
+
sessionId: p.id,
|
|
131
|
+
roleId: p.roleId,
|
|
132
|
+
task: p.task.slice(0, 200),
|
|
133
|
+
status: p.status,
|
|
134
|
+
})),
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import YAML from 'yaml';
|
|
4
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
5
|
+
import { COMPANY_ROOT } from '../services/file-reader.js';
|
|
6
|
+
import { buildOrgTree, type RoleSource } from '../engine/org-tree.js';
|
|
7
|
+
import { RoleLifecycleManager } from '../engine/role-lifecycle.js';
|
|
8
|
+
import { getTokenLedger } from '../services/token-ledger.js';
|
|
9
|
+
import { estimateCost } from '../services/pricing.js';
|
|
10
|
+
import { calcLevel, calcProgress, formatTokens } from '../utils/role-level.js';
|
|
11
|
+
|
|
12
|
+
export const syncRouter = Router();
|
|
13
|
+
|
|
14
|
+
/* ─── GET /api/sync/roles — List roles with source tracking ─── */
|
|
15
|
+
|
|
16
|
+
syncRouter.get('/roles', (_req: Request, res: Response, next: NextFunction) => {
|
|
17
|
+
try {
|
|
18
|
+
const tree = buildOrgTree(COMPANY_ROOT);
|
|
19
|
+
const trackedRoles: Array<{
|
|
20
|
+
roleId: string;
|
|
21
|
+
name: string;
|
|
22
|
+
level: string;
|
|
23
|
+
source: RoleSource;
|
|
24
|
+
persona: string;
|
|
25
|
+
authority: { autonomous: string[]; needsApproval: string[] };
|
|
26
|
+
skills?: string[];
|
|
27
|
+
}> = [];
|
|
28
|
+
|
|
29
|
+
for (const [id, node] of tree.nodes) {
|
|
30
|
+
if (id === 'ceo' || !node.source) continue;
|
|
31
|
+
trackedRoles.push({
|
|
32
|
+
roleId: id,
|
|
33
|
+
name: node.name,
|
|
34
|
+
level: node.level,
|
|
35
|
+
source: node.source,
|
|
36
|
+
persona: node.persona,
|
|
37
|
+
authority: node.authority,
|
|
38
|
+
skills: node.skills,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
res.json({ roles: trackedRoles });
|
|
43
|
+
} catch (err) {
|
|
44
|
+
next(err);
|
|
45
|
+
}
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/* ─── POST /api/sync/apply — Apply upstream changes to a role ─── */
|
|
49
|
+
|
|
50
|
+
syncRouter.post('/apply', async (req: Request, res: Response, next: NextFunction) => {
|
|
51
|
+
try {
|
|
52
|
+
const { roleId, changes, upstreamVersion } = req.body as {
|
|
53
|
+
roleId: string;
|
|
54
|
+
changes: { persona?: string; authority?: { autonomous: string[]; needsApproval: string[] }; skills?: string[] };
|
|
55
|
+
upstreamVersion?: string;
|
|
56
|
+
};
|
|
57
|
+
|
|
58
|
+
if (!roleId || !changes) {
|
|
59
|
+
res.status(400).json({ error: 'roleId and changes are required' });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const manager = new RoleLifecycleManager(COMPANY_ROOT);
|
|
64
|
+
await manager.updateRole(roleId, changes);
|
|
65
|
+
|
|
66
|
+
// Update source.upstream_version if provided
|
|
67
|
+
if (upstreamVersion) {
|
|
68
|
+
const yamlPath = path.join(COMPANY_ROOT, 'knowledge', 'roles', roleId, 'role.yaml');
|
|
69
|
+
const raw = YAML.parse(fs.readFileSync(yamlPath, 'utf-8')) as Record<string, unknown>;
|
|
70
|
+
if (raw.source && typeof raw.source === 'object') {
|
|
71
|
+
(raw.source as Record<string, unknown>).upstream_version = upstreamVersion;
|
|
72
|
+
}
|
|
73
|
+
fs.writeFileSync(yamlPath, YAML.stringify(raw));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
res.json({ ok: true, roleId, applied: Object.keys(changes) });
|
|
77
|
+
} catch (err) {
|
|
78
|
+
next(err);
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
/* ─── GET /api/sync/stats — Company-wide gamification stats ─── */
|
|
83
|
+
|
|
84
|
+
syncRouter.get('/stats', (_req: Request, res: Response, next: NextFunction) => {
|
|
85
|
+
try {
|
|
86
|
+
const tree = buildOrgTree(COMPANY_ROOT);
|
|
87
|
+
const ledger = getTokenLedger(COMPANY_ROOT);
|
|
88
|
+
const summary = ledger.query();
|
|
89
|
+
|
|
90
|
+
// Aggregate by role
|
|
91
|
+
const byRole: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
|
|
92
|
+
const byModel: Record<string, { inputTokens: number; outputTokens: number; costUsd: number }> = {};
|
|
93
|
+
|
|
94
|
+
for (const entry of summary.entries) {
|
|
95
|
+
if (!byRole[entry.roleId]) {
|
|
96
|
+
byRole[entry.roleId] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
97
|
+
}
|
|
98
|
+
byRole[entry.roleId].inputTokens += entry.inputTokens;
|
|
99
|
+
byRole[entry.roleId].outputTokens += entry.outputTokens;
|
|
100
|
+
byRole[entry.roleId].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
101
|
+
|
|
102
|
+
if (!byModel[entry.model]) {
|
|
103
|
+
byModel[entry.model] = { inputTokens: 0, outputTokens: 0, costUsd: 0 };
|
|
104
|
+
}
|
|
105
|
+
byModel[entry.model].inputTokens += entry.inputTokens;
|
|
106
|
+
byModel[entry.model].outputTokens += entry.outputTokens;
|
|
107
|
+
byModel[entry.model].costUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Role count (excluding CEO)
|
|
111
|
+
const roleCount = tree.nodes.size - 1;
|
|
112
|
+
|
|
113
|
+
// Compute per-role levels
|
|
114
|
+
const roleLevels: Array<{
|
|
115
|
+
roleId: string;
|
|
116
|
+
name: string;
|
|
117
|
+
level: number;
|
|
118
|
+
totalTokens: number;
|
|
119
|
+
progress: number;
|
|
120
|
+
formattedTokens: string;
|
|
121
|
+
costUsd: number;
|
|
122
|
+
}> = [];
|
|
123
|
+
|
|
124
|
+
for (const [id, node] of tree.nodes) {
|
|
125
|
+
if (id === 'ceo') continue;
|
|
126
|
+
const roleData = byRole[id];
|
|
127
|
+
const totalTokens = roleData ? roleData.inputTokens + roleData.outputTokens : 0;
|
|
128
|
+
roleLevels.push({
|
|
129
|
+
roleId: id,
|
|
130
|
+
name: node.name,
|
|
131
|
+
level: calcLevel(totalTokens),
|
|
132
|
+
totalTokens,
|
|
133
|
+
progress: calcProgress(totalTokens),
|
|
134
|
+
formattedTokens: formatTokens(totalTokens),
|
|
135
|
+
costUsd: roleData?.costUsd ?? 0,
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Sort by totalTokens desc (leaderboard)
|
|
140
|
+
roleLevels.sort((a, b) => b.totalTokens - a.totalTokens);
|
|
141
|
+
|
|
142
|
+
// Company aggregate
|
|
143
|
+
const totalTokens = summary.totalInput + summary.totalOutput;
|
|
144
|
+
let totalCostUsd = 0;
|
|
145
|
+
for (const entry of summary.entries) {
|
|
146
|
+
totalCostUsd += estimateCost(entry.inputTokens, entry.outputTokens, entry.model);
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
res.json({
|
|
150
|
+
company: {
|
|
151
|
+
roleCount,
|
|
152
|
+
totalTokens,
|
|
153
|
+
formattedTokens: formatTokens(totalTokens),
|
|
154
|
+
totalCostUsd,
|
|
155
|
+
avgLevel: roleLevels.length > 0
|
|
156
|
+
? Math.round(roleLevels.reduce((sum, r) => sum + r.level, 0) / roleLevels.length * 10) / 10
|
|
157
|
+
: 1,
|
|
158
|
+
},
|
|
159
|
+
roles: roleLevels,
|
|
160
|
+
byModel,
|
|
161
|
+
});
|
|
162
|
+
} catch (err) {
|
|
163
|
+
next(err);
|
|
164
|
+
}
|
|
165
|
+
});
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { COMPANY_ROOT } from './services/file-reader.js';
|
|
2
|
+
import { applyConfig } from './services/company-config.js';
|
|
3
|
+
import { createHttpServer } from './create-server.js';
|
|
4
|
+
import { listSessions, updateSession } from './services/session-store.js';
|
|
5
|
+
import { ActivityStream } from './services/activity-stream.js';
|
|
6
|
+
|
|
7
|
+
// Load .tycono/config.json and apply to process.env
|
|
8
|
+
const config = applyConfig(COMPANY_ROOT);
|
|
9
|
+
console.log(`[STARTUP] Engine: ${config.engine}, API key: ${config.apiKey ? 'set' : 'none'}`);
|
|
10
|
+
|
|
11
|
+
// Startup: mark orphaned 'active' sessions as 'interrupted'
|
|
12
|
+
// These are sessions from a previous server that crashed or was killed
|
|
13
|
+
{
|
|
14
|
+
const allSessions = listSessions();
|
|
15
|
+
let orphaned = 0;
|
|
16
|
+
for (const ses of allSessions) {
|
|
17
|
+
if (ses.status !== 'active') continue;
|
|
18
|
+
// Check activity stream — if it has msg:done/msg:error, mark done
|
|
19
|
+
// If not, mark interrupted (previous server died mid-execution)
|
|
20
|
+
if (ActivityStream.exists(ses.id)) {
|
|
21
|
+
const events = ActivityStream.readFrom(ses.id, 0);
|
|
22
|
+
const tail = events.slice(-5);
|
|
23
|
+
const isDone = tail.some(e => e.type === 'msg:done' || e.type === 'msg:error');
|
|
24
|
+
if (isDone) {
|
|
25
|
+
updateSession(ses.id, { status: 'done' });
|
|
26
|
+
orphaned++;
|
|
27
|
+
continue;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
updateSession(ses.id, { status: 'interrupted' as any });
|
|
31
|
+
orphaned++;
|
|
32
|
+
}
|
|
33
|
+
if (orphaned > 0) {
|
|
34
|
+
console.log(`[STARTUP] Cleaned ${orphaned} orphaned sessions (active → done/interrupted)`);
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const PORT = Number(process.env.PORT) || 3001;
|
|
39
|
+
const server = createHttpServer();
|
|
40
|
+
|
|
41
|
+
server.listen(PORT, () => {
|
|
42
|
+
console.log(`[API] Server running on http://localhost:${PORT}`);
|
|
43
|
+
console.log(`[API] COMPANY_ROOT: ${COMPANY_ROOT}`);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
// Graceful shutdown: mark running sessions as interrupted
|
|
47
|
+
function gracefulShutdown(signal: string) {
|
|
48
|
+
console.log(`[SHUTDOWN] ${signal} received, marking active sessions as interrupted...`);
|
|
49
|
+
const sessions = listSessions();
|
|
50
|
+
for (const ses of sessions) {
|
|
51
|
+
if (ses.status === 'active') {
|
|
52
|
+
updateSession(ses.id, { status: 'interrupted' as any });
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
process.exit(0);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
process.on('SIGINT', () => gracefulShutdown('SIGINT'));
|
|
59
|
+
process.on('SIGTERM', () => gracefulShutdown('SIGTERM'));
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { COMPANY_ROOT } from './file-reader.js';
|
|
4
|
+
|
|
5
|
+
/* ─── Types (re-export from shared contract) ── */
|
|
6
|
+
|
|
7
|
+
export { type ActivityEventType, type ActivityEvent } from '../../../shared/types.js';
|
|
8
|
+
import type { ActivityEventType, ActivityEvent } from '../../../shared/types.js';
|
|
9
|
+
|
|
10
|
+
/* ─── Constants ──────────────────────────── */
|
|
11
|
+
|
|
12
|
+
function streamsDir(): string {
|
|
13
|
+
return path.join(COMPANY_ROOT, '.tycono', 'activity-streams');
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function ensureDir(): void {
|
|
17
|
+
const dir = streamsDir();
|
|
18
|
+
if (!fs.existsSync(dir)) {
|
|
19
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function streamPath(streamId: string): string {
|
|
24
|
+
return path.join(streamsDir(), `${streamId}.jsonl`);
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/* ─── Subscriber type ────────────────────── */
|
|
28
|
+
|
|
29
|
+
export type ActivitySubscriber = (event: ActivityEvent) => void;
|
|
30
|
+
|
|
31
|
+
/* ─── ActivityStream ─────────────────────── */
|
|
32
|
+
|
|
33
|
+
export class ActivityStream {
|
|
34
|
+
readonly sessionId: string;
|
|
35
|
+
readonly roleId: string;
|
|
36
|
+
readonly parentSessionId?: string;
|
|
37
|
+
/** Trace ID for full chain tracking — top-level sessionId propagated to all children */
|
|
38
|
+
readonly traceId?: string;
|
|
39
|
+
|
|
40
|
+
private seq = 0;
|
|
41
|
+
private subscribers = new Set<ActivitySubscriber>();
|
|
42
|
+
private filePath: string;
|
|
43
|
+
private closed = false;
|
|
44
|
+
|
|
45
|
+
constructor(sessionId: string, roleId: string, parentSessionId?: string, traceId?: string) {
|
|
46
|
+
this.sessionId = sessionId;
|
|
47
|
+
this.roleId = roleId;
|
|
48
|
+
this.parentSessionId = parentSessionId;
|
|
49
|
+
this.traceId = traceId;
|
|
50
|
+
|
|
51
|
+
ensureDir();
|
|
52
|
+
this.filePath = streamPath(sessionId);
|
|
53
|
+
|
|
54
|
+
// Resume mode: if file already exists, read last seq and continue
|
|
55
|
+
if (fs.existsSync(this.filePath)) {
|
|
56
|
+
const content = fs.readFileSync(this.filePath, 'utf-8');
|
|
57
|
+
const lines = content.split('\n').filter(Boolean);
|
|
58
|
+
if (lines.length > 0) {
|
|
59
|
+
try {
|
|
60
|
+
const lastEvent = JSON.parse(lines[lines.length - 1]) as ActivityEvent;
|
|
61
|
+
this.seq = lastEvent.seq + 1;
|
|
62
|
+
} catch { /* start from 0 if parse fails */ }
|
|
63
|
+
}
|
|
64
|
+
} else {
|
|
65
|
+
// Create empty file
|
|
66
|
+
fs.writeFileSync(this.filePath, '', { flag: 'w' });
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Append event to JSONL + push to live subscribers */
|
|
71
|
+
emit(type: ActivityEventType, roleId: string, data: Record<string, unknown>): ActivityEvent {
|
|
72
|
+
const event: ActivityEvent = {
|
|
73
|
+
seq: this.seq++,
|
|
74
|
+
ts: new Date().toISOString(),
|
|
75
|
+
type,
|
|
76
|
+
roleId,
|
|
77
|
+
parentSessionId: this.parentSessionId,
|
|
78
|
+
...(this.traceId && { traceId: this.traceId }),
|
|
79
|
+
data,
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
// Append to file
|
|
83
|
+
try {
|
|
84
|
+
fs.appendFileSync(this.filePath, JSON.stringify(event) + '\n');
|
|
85
|
+
} catch {
|
|
86
|
+
// File write failure shouldn't crash the stream
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Push to subscribers
|
|
90
|
+
for (const cb of this.subscribers) {
|
|
91
|
+
try { cb(event); } catch { /* subscriber errors don't affect others */ }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return event;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
subscribe(cb: ActivitySubscriber): void {
|
|
98
|
+
this.subscribers.add(cb);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
unsubscribe(cb: ActivitySubscriber): void {
|
|
102
|
+
this.subscribers.delete(cb);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
get subscriberCount(): number {
|
|
106
|
+
return this.subscribers.size;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
close(): void {
|
|
110
|
+
this.closed = true;
|
|
111
|
+
this.subscribers.clear();
|
|
112
|
+
// Memory: remove from activeStreams cache
|
|
113
|
+
ActivityStream.activeStreams.delete(this.sessionId);
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
get isClosed(): boolean {
|
|
117
|
+
return this.closed;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
get lastSeq(): number {
|
|
121
|
+
return this.seq;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/* ─── Static factory ─────────────────── */
|
|
125
|
+
|
|
126
|
+
/** Cache of active streams by sessionId */
|
|
127
|
+
private static activeStreams = new Map<string, ActivityStream>();
|
|
128
|
+
|
|
129
|
+
/** Get or create an ActivityStream for a session. Reuses existing stream for continuations. */
|
|
130
|
+
static getOrCreate(sessionId: string, roleId: string, parentSessionId?: string, traceId?: string): ActivityStream {
|
|
131
|
+
const existing = ActivityStream.activeStreams.get(sessionId);
|
|
132
|
+
if (existing && !existing.isClosed) {
|
|
133
|
+
return existing;
|
|
134
|
+
}
|
|
135
|
+
const stream = new ActivityStream(sessionId, roleId, parentSessionId, traceId);
|
|
136
|
+
ActivityStream.activeStreams.set(sessionId, stream);
|
|
137
|
+
return stream;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
/* ─── Static: read from file ───────────── */
|
|
141
|
+
|
|
142
|
+
/** Read events from a JSONL file starting at fromSeq */
|
|
143
|
+
static readFrom(streamId: string, fromSeq = 0): ActivityEvent[] {
|
|
144
|
+
const fp = streamPath(streamId);
|
|
145
|
+
if (!fs.existsSync(fp)) return [];
|
|
146
|
+
|
|
147
|
+
const lines = fs.readFileSync(fp, 'utf-8').split('\n').filter(Boolean);
|
|
148
|
+
const events: ActivityEvent[] = [];
|
|
149
|
+
|
|
150
|
+
for (const line of lines) {
|
|
151
|
+
try {
|
|
152
|
+
const event = JSON.parse(line) as ActivityEvent;
|
|
153
|
+
if (event.seq >= fromSeq) {
|
|
154
|
+
events.push(event);
|
|
155
|
+
}
|
|
156
|
+
} catch { /* skip malformed lines */ }
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return events;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Read all events from a JSONL file */
|
|
163
|
+
static readAll(streamId: string): ActivityEvent[] {
|
|
164
|
+
return ActivityStream.readFrom(streamId, 0);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Check if a stream file exists */
|
|
168
|
+
static exists(streamId: string): boolean {
|
|
169
|
+
return fs.existsSync(streamPath(streamId));
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** List all stream files (stream IDs) */
|
|
173
|
+
static listAll(): string[] {
|
|
174
|
+
ensureDir();
|
|
175
|
+
return fs.readdirSync(streamsDir())
|
|
176
|
+
.filter(f => f.endsWith('.jsonl'))
|
|
177
|
+
.map(f => f.replace('.jsonl', ''));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/** Get the streams directory path */
|
|
181
|
+
static getStreamDir(): string {
|
|
182
|
+
return streamsDir();
|
|
183
|
+
}
|
|
184
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { COMPANY_ROOT } from './file-reader.js';
|
|
4
|
+
import type { RoleStatus } from '../../../shared/types.js';
|
|
5
|
+
|
|
6
|
+
function activityDir(): string {
|
|
7
|
+
return path.join(COMPANY_ROOT, '.tycono', 'activity');
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface RoleActivity {
|
|
11
|
+
roleId: string;
|
|
12
|
+
status: RoleStatus;
|
|
13
|
+
currentTask: string;
|
|
14
|
+
startedAt: string;
|
|
15
|
+
updatedAt: string;
|
|
16
|
+
recentOutput: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function activityPath(roleId: string): string {
|
|
20
|
+
return path.join(activityDir(), `${roleId}.json`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function ensureDir(): void {
|
|
24
|
+
const dir = activityDir();
|
|
25
|
+
if (!fs.existsSync(dir)) {
|
|
26
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function setActivity(roleId: string, task: string): void {
|
|
31
|
+
ensureDir();
|
|
32
|
+
const activity: RoleActivity = {
|
|
33
|
+
roleId,
|
|
34
|
+
status: 'working',
|
|
35
|
+
currentTask: task,
|
|
36
|
+
startedAt: new Date().toISOString(),
|
|
37
|
+
updatedAt: new Date().toISOString(),
|
|
38
|
+
recentOutput: '',
|
|
39
|
+
};
|
|
40
|
+
fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
|
|
41
|
+
invalidateCache();
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function updateActivity(roleId: string, output: string): void {
|
|
45
|
+
const activity = getActivity(roleId);
|
|
46
|
+
if (!activity) return;
|
|
47
|
+
activity.updatedAt = new Date().toISOString();
|
|
48
|
+
activity.recentOutput = output.slice(-500);
|
|
49
|
+
fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
|
|
50
|
+
invalidateCache();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export function markAwaitingInput(roleId: string): void {
|
|
54
|
+
const activity = getActivity(roleId);
|
|
55
|
+
if (!activity) return;
|
|
56
|
+
activity.status = 'awaiting_input';
|
|
57
|
+
activity.updatedAt = new Date().toISOString();
|
|
58
|
+
fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
|
|
59
|
+
invalidateCache();
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function completeActivity(roleId: string): void {
|
|
63
|
+
const activity = getActivity(roleId);
|
|
64
|
+
if (!activity) return;
|
|
65
|
+
activity.status = 'done';
|
|
66
|
+
activity.updatedAt = new Date().toISOString();
|
|
67
|
+
fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
|
|
68
|
+
invalidateCache();
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function clearActivity(roleId: string): void {
|
|
72
|
+
const filePath = activityPath(roleId);
|
|
73
|
+
if (fs.existsSync(filePath)) {
|
|
74
|
+
fs.unlinkSync(filePath);
|
|
75
|
+
}
|
|
76
|
+
invalidateCache();
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function getActivity(roleId: string): RoleActivity | null {
|
|
80
|
+
const filePath = activityPath(roleId);
|
|
81
|
+
if (!fs.existsSync(filePath)) return null;
|
|
82
|
+
try {
|
|
83
|
+
return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
|
|
84
|
+
} catch {
|
|
85
|
+
return null;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/** Cached getAllActivities — avoids re-reading files within TTL window */
|
|
90
|
+
let _activitiesCache: RoleActivity[] | null = null;
|
|
91
|
+
let _activitiesCacheTs = 0;
|
|
92
|
+
const ACTIVITIES_CACHE_TTL = 500; // ms
|
|
93
|
+
|
|
94
|
+
export function getAllActivities(): RoleActivity[] {
|
|
95
|
+
const now = Date.now();
|
|
96
|
+
if (_activitiesCache && now - _activitiesCacheTs < ACTIVITIES_CACHE_TTL) {
|
|
97
|
+
return _activitiesCache;
|
|
98
|
+
}
|
|
99
|
+
ensureDir();
|
|
100
|
+
const files = fs.readdirSync(activityDir()).filter(f => f.endsWith('.json'));
|
|
101
|
+
_activitiesCache = files.map(f => {
|
|
102
|
+
try {
|
|
103
|
+
return JSON.parse(fs.readFileSync(path.join(activityDir(), f), 'utf-8'));
|
|
104
|
+
} catch {
|
|
105
|
+
return null;
|
|
106
|
+
}
|
|
107
|
+
}).filter((a): a is RoleActivity => a !== null);
|
|
108
|
+
_activitiesCacheTs = now;
|
|
109
|
+
return _activitiesCache;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** Invalidate cache after writes (setActivity, updateActivity, completeActivity) */
|
|
113
|
+
function invalidateCache(): void {
|
|
114
|
+
_activitiesCache = null;
|
|
115
|
+
}
|