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,112 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { readFile, fileExists, listFiles } from '../services/file-reader.js';
|
|
3
|
+
import { parseMarkdownTable } from '../services/markdown-parser.js';
|
|
4
|
+
import YAML from 'yaml';
|
|
5
|
+
|
|
6
|
+
export const rolesRouter = Router();
|
|
7
|
+
|
|
8
|
+
// GET /api/roles — Role 목록
|
|
9
|
+
rolesRouter.get('/', (_req: Request, res: Response, next: NextFunction) => {
|
|
10
|
+
try {
|
|
11
|
+
const content = readFile('knowledge/roles/roles.md');
|
|
12
|
+
const rows = parseMarkdownTable(content);
|
|
13
|
+
|
|
14
|
+
const roles = rows.map(row => {
|
|
15
|
+
const id = row.id ?? '';
|
|
16
|
+
let name = row.role ?? row.name ?? '';
|
|
17
|
+
|
|
18
|
+
// role.yaml의 name이 있으면 우선 사용 (rename 반영)
|
|
19
|
+
const yamlPath = `knowledge/roles/${id}/role.yaml`;
|
|
20
|
+
if (id && fileExists(yamlPath)) {
|
|
21
|
+
try {
|
|
22
|
+
const raw = YAML.parse(readFile(yamlPath)) as Record<string, unknown>;
|
|
23
|
+
if (raw.name) name = raw.name as string;
|
|
24
|
+
} catch { /* fallback to roles.md name */ }
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
return {
|
|
28
|
+
id,
|
|
29
|
+
name,
|
|
30
|
+
level: row.level ?? '',
|
|
31
|
+
reportsTo: row.reports_to ?? '',
|
|
32
|
+
status: row.상태 ?? row.status ?? '',
|
|
33
|
+
};
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
res.json(roles);
|
|
37
|
+
} catch (err) {
|
|
38
|
+
next(err);
|
|
39
|
+
}
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
// GET /api/roles/:id — Role 상세
|
|
43
|
+
rolesRouter.get('/:id', (req: Request, res: Response, next: NextFunction) => {
|
|
44
|
+
try {
|
|
45
|
+
const { id } = req.params;
|
|
46
|
+
|
|
47
|
+
// 기본 정보 (roles.md 테이블에서)
|
|
48
|
+
const listContent = readFile('knowledge/roles/roles.md');
|
|
49
|
+
const rows = parseMarkdownTable(listContent);
|
|
50
|
+
const roleRow = rows.find(r => r.id === id);
|
|
51
|
+
|
|
52
|
+
if (!roleRow) {
|
|
53
|
+
res.status(404).json({ error: `Role not found: ${id}` });
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const role: Record<string, unknown> = {
|
|
58
|
+
id: roleRow.id,
|
|
59
|
+
name: roleRow.role ?? roleRow.name ?? '',
|
|
60
|
+
level: roleRow.level ?? '',
|
|
61
|
+
reportsTo: roleRow.reports_to ?? '',
|
|
62
|
+
status: roleRow.상태 ?? roleRow.status ?? '',
|
|
63
|
+
persona: '',
|
|
64
|
+
authority: { autonomous: [] as string[], needsApproval: [] as string[] },
|
|
65
|
+
journal: '',
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
// role.yaml에서 name + persona + authority + skills 읽기
|
|
69
|
+
const yamlPath = `knowledge/roles/${id}/role.yaml`;
|
|
70
|
+
if (fileExists(yamlPath)) {
|
|
71
|
+
const raw = YAML.parse(readFile(yamlPath)) as Record<string, unknown>;
|
|
72
|
+
if (raw.name) role.name = raw.name;
|
|
73
|
+
if (raw.persona) role.persona = raw.persona;
|
|
74
|
+
if (Array.isArray(raw.skills)) role.skills = raw.skills;
|
|
75
|
+
const auth = raw.authority as Record<string, string[]> | undefined;
|
|
76
|
+
if (auth) {
|
|
77
|
+
role.authority = {
|
|
78
|
+
autonomous: auth.autonomous ?? [],
|
|
79
|
+
needsApproval: auth.needs_approval ?? [],
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// SKILL.md에서 스킬 메타 자동 추출
|
|
85
|
+
const skillMdPath = `.claude/skills/${id}/SKILL.md`;
|
|
86
|
+
if (fileExists(skillMdPath)) {
|
|
87
|
+
const skillContent = readFile(skillMdPath);
|
|
88
|
+
const fmMatch = skillContent.match(/^---\n([\s\S]*?)\n---/);
|
|
89
|
+
if (fmMatch) {
|
|
90
|
+
try {
|
|
91
|
+
const meta = YAML.parse(fmMatch[1]) as Record<string, unknown>;
|
|
92
|
+
role.skillMeta = {
|
|
93
|
+
name: meta.name || id,
|
|
94
|
+
description: meta.description || '',
|
|
95
|
+
...(meta.allowedTools ? { allowedTools: meta.allowedTools } : {}),
|
|
96
|
+
};
|
|
97
|
+
} catch { /* ignore parse errors */ }
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// 오늘 저널 읽기
|
|
102
|
+
const today = new Date().toISOString().slice(0, 10);
|
|
103
|
+
const journalPath = `knowledge/roles/${id}/journal/${today}.md`;
|
|
104
|
+
if (fileExists(journalPath)) {
|
|
105
|
+
role.journal = readFile(journalPath).slice(0, 3000);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
res.json(role);
|
|
109
|
+
} catch (err) {
|
|
110
|
+
next(err);
|
|
111
|
+
}
|
|
112
|
+
});
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { Router, Request, Response, NextFunction } from 'express';
|
|
2
|
+
import { COMPANY_ROOT } from '../services/file-reader.js';
|
|
3
|
+
import { getGitStatus, gitSave, gitHistory, gitRestore, gitInit, gitFetchStatus, gitPull, githubStatus, githubCreateRepo, gitAddRemote } from '../services/git-save.js';
|
|
4
|
+
import type { RepoType } from '../services/git-save.js';
|
|
5
|
+
|
|
6
|
+
export const saveRouter = Router();
|
|
7
|
+
|
|
8
|
+
/** Extract repo type from query param, default 'akb' */
|
|
9
|
+
function getRepo(req: Request): RepoType {
|
|
10
|
+
const repo = req.query.repo;
|
|
11
|
+
return repo === 'code' ? 'code' : 'akb';
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
// GET /api/save/status?repo=akb|code
|
|
15
|
+
saveRouter.get('/status', (req: Request, res: Response, next: NextFunction) => {
|
|
16
|
+
try {
|
|
17
|
+
res.json(getGitStatus(COMPANY_ROOT, getRepo(req)));
|
|
18
|
+
} catch (err) {
|
|
19
|
+
// Not a git repo (e.g. auto-created code dir) — return empty status
|
|
20
|
+
if (err instanceof Error && (err.message.includes('not a git repository') || err.message.includes('codeRoot'))) {
|
|
21
|
+
res.json({ isRepo: false, branch: '', staged: [], unstaged: [], untracked: [] });
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
next(err);
|
|
25
|
+
}
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
// POST /api/save?repo=akb|code — commit + push
|
|
29
|
+
saveRouter.post('/', (req: Request, res: Response, next: NextFunction) => {
|
|
30
|
+
try {
|
|
31
|
+
const { message } = req.body ?? {};
|
|
32
|
+
const result = gitSave(COMPANY_ROOT, message, getRepo(req));
|
|
33
|
+
res.json({ ok: true, ...result });
|
|
34
|
+
} catch (err) {
|
|
35
|
+
if (err instanceof Error) {
|
|
36
|
+
if (err.message === 'No changes to save') {
|
|
37
|
+
res.status(400).json({ error: err.message });
|
|
38
|
+
return;
|
|
39
|
+
}
|
|
40
|
+
if (err.message.includes('Not a git repository') || err.message.includes('codeRoot')) {
|
|
41
|
+
res.status(400).json({ error: 'Repository not initialized. Run git init first.' });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
next(err);
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
// GET /api/save/history?repo=akb|code
|
|
50
|
+
saveRouter.get('/history', (req: Request, res: Response, next: NextFunction) => {
|
|
51
|
+
try {
|
|
52
|
+
const limit = Math.min(Number(req.query.limit) || 20, 100);
|
|
53
|
+
res.json(gitHistory(COMPANY_ROOT, limit, getRepo(req)));
|
|
54
|
+
} catch (err) {
|
|
55
|
+
next(err);
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
// POST /api/save/init — initialize git repo
|
|
60
|
+
saveRouter.post('/init', (req: Request, res: Response, next: NextFunction) => {
|
|
61
|
+
try {
|
|
62
|
+
const result = gitInit(COMPANY_ROOT, getRepo(req));
|
|
63
|
+
if (!result.ok) {
|
|
64
|
+
res.status(500).json({ error: result.message });
|
|
65
|
+
return;
|
|
66
|
+
}
|
|
67
|
+
res.json(result);
|
|
68
|
+
} catch (err) {
|
|
69
|
+
next(err);
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
// POST /api/save/restore?repo=akb|code
|
|
74
|
+
saveRouter.post('/restore', (req: Request, res: Response, next: NextFunction) => {
|
|
75
|
+
try {
|
|
76
|
+
const { sha, paths } = req.body ?? {};
|
|
77
|
+
if (!sha || typeof sha !== 'string') {
|
|
78
|
+
res.status(400).json({ error: 'sha is required' });
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const result = gitRestore(COMPANY_ROOT, sha, paths, getRepo(req));
|
|
82
|
+
res.json({ ok: true, ...result });
|
|
83
|
+
} catch (err) {
|
|
84
|
+
if (err instanceof Error && (err.message.includes('Not a git repository') || err.message.includes('codeRoot'))) {
|
|
85
|
+
res.status(400).json({ error: 'Repository not initialized. Run git init first.' });
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
next(err);
|
|
89
|
+
}
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// GET /api/save/sync-status?repo=akb|code — fetch + ahead/behind
|
|
93
|
+
saveRouter.get('/sync-status', (req: Request, res: Response, next: NextFunction) => {
|
|
94
|
+
try {
|
|
95
|
+
res.json(gitFetchStatus(COMPANY_ROOT, getRepo(req)));
|
|
96
|
+
} catch (err) {
|
|
97
|
+
next(err);
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
// POST /api/save/pull?repo=akb|code — safe pull (ff-only)
|
|
102
|
+
saveRouter.post('/pull', (req: Request, res: Response, next: NextFunction) => {
|
|
103
|
+
try {
|
|
104
|
+
const result = gitPull(COMPANY_ROOT, getRepo(req));
|
|
105
|
+
const statusCode = result.status === 'ok' || result.status === 'up-to-date' ? 200
|
|
106
|
+
: result.status === 'dirty' || result.status === 'diverged' ? 409
|
|
107
|
+
: result.status === 'no-remote' ? 404
|
|
108
|
+
: 500;
|
|
109
|
+
res.status(statusCode).json(result);
|
|
110
|
+
} catch (err) {
|
|
111
|
+
next(err);
|
|
112
|
+
}
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
// GET /api/save/github-status?repo=akb|code — check gh CLI + auth + remote
|
|
116
|
+
saveRouter.get('/github-status', (req: Request, res: Response, next: NextFunction) => {
|
|
117
|
+
try {
|
|
118
|
+
res.json(githubStatus(COMPANY_ROOT, getRepo(req)));
|
|
119
|
+
} catch (err) {
|
|
120
|
+
next(err);
|
|
121
|
+
}
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
// POST /api/save/github-create-repo?repo=akb|code — create GitHub repo + push
|
|
125
|
+
saveRouter.post('/github-create-repo', (req: Request, res: Response, next: NextFunction) => {
|
|
126
|
+
try {
|
|
127
|
+
const { name, visibility } = req.body ?? {};
|
|
128
|
+
if (!name || typeof name !== 'string') {
|
|
129
|
+
res.status(400).json({ ok: false, message: 'Repository name is required' });
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
const result = githubCreateRepo(COMPANY_ROOT, name, visibility || 'private', getRepo(req));
|
|
133
|
+
res.status(result.ok ? 200 : 400).json(result);
|
|
134
|
+
} catch (err) {
|
|
135
|
+
next(err);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// POST /api/save/remote?repo=akb|code — manually add git remote
|
|
140
|
+
saveRouter.post('/remote', (req: Request, res: Response, next: NextFunction) => {
|
|
141
|
+
try {
|
|
142
|
+
const { url } = req.body ?? {};
|
|
143
|
+
if (!url || typeof url !== 'string') {
|
|
144
|
+
res.status(400).json({ ok: false, message: 'Remote URL is required' });
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
const result = gitAddRemote(COMPANY_ROOT, url, getRepo(req));
|
|
148
|
+
res.status(result.ok ? 200 : 400).json(result);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
next(err);
|
|
151
|
+
}
|
|
152
|
+
});
|
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
import { Router } from 'express';
|
|
2
|
+
import {
|
|
3
|
+
createSession,
|
|
4
|
+
getSession,
|
|
5
|
+
listSessions,
|
|
6
|
+
deleteSession,
|
|
7
|
+
deleteMany,
|
|
8
|
+
deleteEmpty,
|
|
9
|
+
updateSession,
|
|
10
|
+
addMessage,
|
|
11
|
+
type Message,
|
|
12
|
+
} from '../services/session-store.js';
|
|
13
|
+
import { executionManager } from '../services/execution-manager.js';
|
|
14
|
+
import { isMessageActive, type MessageStatus } from '../../../shared/types.js';
|
|
15
|
+
import { ActivityStream, type ActivityEvent } from '../services/activity-stream.js';
|
|
16
|
+
import { updateFollowUpForReply } from '../services/wave-tracker.js';
|
|
17
|
+
|
|
18
|
+
export const sessionsRouter = Router();
|
|
19
|
+
|
|
20
|
+
/* POST /api/sessions — create session */
|
|
21
|
+
sessionsRouter.post('/', (req, res) => {
|
|
22
|
+
const { roleId, mode } = req.body;
|
|
23
|
+
if (!roleId) {
|
|
24
|
+
res.status(400).json({ error: 'roleId is required' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
const session = createSession(roleId, { mode: mode ?? 'talk' });
|
|
28
|
+
res.status(201).json(session);
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
/* GET /api/sessions — list sessions (meta only) */
|
|
32
|
+
sessionsRouter.get('/', (_req, res) => {
|
|
33
|
+
res.json(listSessions());
|
|
34
|
+
});
|
|
35
|
+
|
|
36
|
+
/* GET /api/sessions/:id — session detail with messages */
|
|
37
|
+
sessionsRouter.get('/:id', (req, res) => {
|
|
38
|
+
const session = getSession(req.params.id);
|
|
39
|
+
if (!session) {
|
|
40
|
+
res.status(404).json({ error: 'Session not found' });
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
res.json(session);
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
/* PATCH /api/sessions/:id — update title/mode */
|
|
47
|
+
sessionsRouter.patch('/:id', (req, res) => {
|
|
48
|
+
const { title, mode } = req.body;
|
|
49
|
+
const session = updateSession(req.params.id, { title, mode });
|
|
50
|
+
if (!session) {
|
|
51
|
+
res.status(404).json({ error: 'Session not found' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
res.json(session);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
/* DELETE /api/sessions — bulk delete (body: { ids }) or ?empty=true */
|
|
58
|
+
sessionsRouter.delete('/', (req, res) => {
|
|
59
|
+
console.log(`[Sessions] DELETE / called (empty=${req.query.empty}, origin=${req.headers.origin ?? req.headers.referer ?? 'unknown'})`);
|
|
60
|
+
if (req.query.empty === 'true') {
|
|
61
|
+
const result = deleteEmpty();
|
|
62
|
+
res.json(result);
|
|
63
|
+
return;
|
|
64
|
+
}
|
|
65
|
+
const { ids } = req.body ?? {};
|
|
66
|
+
if (!Array.isArray(ids) || ids.length === 0) {
|
|
67
|
+
res.status(400).json({ error: 'ids array is required' });
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const deleted = deleteMany(ids);
|
|
71
|
+
res.json({ deleted });
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
/* DELETE /api/sessions/:id — delete session */
|
|
75
|
+
sessionsRouter.delete('/:id', (req, res) => {
|
|
76
|
+
console.log(`[Sessions] DELETE /${req.params.id} called (origin=${req.headers.origin ?? req.headers.referer ?? 'unknown'})`);
|
|
77
|
+
const ok = deleteSession(req.params.id);
|
|
78
|
+
if (!ok) {
|
|
79
|
+
res.status(404).json({ error: 'Session not found' });
|
|
80
|
+
return;
|
|
81
|
+
}
|
|
82
|
+
res.json({ ok: true });
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
/* ─── Session-based execution proxying ──── */
|
|
86
|
+
|
|
87
|
+
/** GET /api/sessions/:id/stream — SSE proxy to linked execution's activity stream */
|
|
88
|
+
sessionsRouter.get('/:id/stream', (req, res) => {
|
|
89
|
+
const session = getSession(req.params.id);
|
|
90
|
+
if (!session) {
|
|
91
|
+
res.status(404).json({ error: 'Session not found' });
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const exec = executionManager.getActiveExecution(req.params.id);
|
|
96
|
+
const fromSeq = parseInt(req.query.from as string ?? '0', 10);
|
|
97
|
+
|
|
98
|
+
res.writeHead(200, {
|
|
99
|
+
'Content-Type': 'text/event-stream',
|
|
100
|
+
'Cache-Control': 'no-cache',
|
|
101
|
+
'Connection': 'keep-alive',
|
|
102
|
+
'X-Accel-Buffering': 'no',
|
|
103
|
+
});
|
|
104
|
+
res.flushHeaders();
|
|
105
|
+
|
|
106
|
+
const sendEvent = (event: string, data: unknown) => {
|
|
107
|
+
if (res.destroyed || res.writableEnded) return;
|
|
108
|
+
try { res.write(`event: ${event}\ndata: ${JSON.stringify(data)}\n\n`); } catch { /* ignore */ }
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
// Read from session-keyed stream file
|
|
112
|
+
const streamId = exec?.sessionId ?? req.params.id;
|
|
113
|
+
if (ActivityStream.exists(streamId)) {
|
|
114
|
+
const pastEvents = ActivityStream.readFrom(streamId, fromSeq);
|
|
115
|
+
for (const event of pastEvents) {
|
|
116
|
+
sendEvent('activity', event);
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!exec || !isMessageActive(exec.status as MessageStatus)) {
|
|
121
|
+
sendEvent('stream:end', { reason: exec ? exec.status : 'no-execution' });
|
|
122
|
+
res.end();
|
|
123
|
+
return;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const subscriber = (event: ActivityEvent) => {
|
|
127
|
+
if (event.seq >= fromSeq) {
|
|
128
|
+
sendEvent('activity', event);
|
|
129
|
+
}
|
|
130
|
+
if (event.type === 'msg:done' || event.type === 'msg:error') {
|
|
131
|
+
sendEvent('stream:end', { reason: event.type === 'msg:done' ? 'done' : 'error' });
|
|
132
|
+
res.end();
|
|
133
|
+
exec.stream.unsubscribe(subscriber);
|
|
134
|
+
} else if (event.type === 'msg:awaiting_input') {
|
|
135
|
+
sendEvent('stream:end', { reason: 'awaiting_input' });
|
|
136
|
+
res.end();
|
|
137
|
+
exec.stream.unsubscribe(subscriber);
|
|
138
|
+
} else if (event.type === 'msg:reply') {
|
|
139
|
+
sendEvent('stream:end', { reason: 'replied' });
|
|
140
|
+
res.end();
|
|
141
|
+
exec.stream.unsubscribe(subscriber);
|
|
142
|
+
}
|
|
143
|
+
};
|
|
144
|
+
|
|
145
|
+
exec.stream.subscribe(subscriber);
|
|
146
|
+
|
|
147
|
+
const heartbeat = setInterval(() => {
|
|
148
|
+
if (res.destroyed || res.writableEnded) {
|
|
149
|
+
clearInterval(heartbeat);
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
try { res.write(': heartbeat\n\n'); } catch { /* ignore */ }
|
|
153
|
+
}, 15_000);
|
|
154
|
+
|
|
155
|
+
req.on('close', () => {
|
|
156
|
+
clearInterval(heartbeat);
|
|
157
|
+
exec.stream.unsubscribe(subscriber);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
/** POST /api/sessions/:id/abort — abort linked execution */
|
|
162
|
+
sessionsRouter.post('/:id/abort', (req, res) => {
|
|
163
|
+
const success = executionManager.abortSession(req.params.id);
|
|
164
|
+
if (!success) {
|
|
165
|
+
res.status(404).json({ error: 'No active execution for this session' });
|
|
166
|
+
return;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
res.json({ ok: true, sessionId: req.params.id });
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
/** POST /api/sessions/:id/message — send a new message to the session */
|
|
173
|
+
sessionsRouter.post('/:id/message', (req, res) => {
|
|
174
|
+
const session = getSession(req.params.id);
|
|
175
|
+
if (!session) {
|
|
176
|
+
res.status(404).json({ error: 'Session not found' });
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const { message, sourceRole, attachments } = req.body;
|
|
181
|
+
if (!message && (!attachments || attachments.length === 0)) {
|
|
182
|
+
res.status(400).json({ error: 'message or attachments required' });
|
|
183
|
+
return;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const ceoMsg: Message = {
|
|
187
|
+
id: `msg-${Date.now()}-ceo-msg`,
|
|
188
|
+
from: 'ceo',
|
|
189
|
+
content: message ?? '',
|
|
190
|
+
type: 'conversation',
|
|
191
|
+
status: 'done',
|
|
192
|
+
timestamp: new Date().toISOString(),
|
|
193
|
+
attachments,
|
|
194
|
+
};
|
|
195
|
+
addMessage(req.params.id, ceoMsg);
|
|
196
|
+
|
|
197
|
+
const newExec = executionManager.startExecution({
|
|
198
|
+
type: 'assign',
|
|
199
|
+
roleId: session.roleId,
|
|
200
|
+
task: message ?? '(image attached)',
|
|
201
|
+
sourceRole: sourceRole ?? 'ceo',
|
|
202
|
+
sessionId: req.params.id,
|
|
203
|
+
attachments,
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const roleMsg: Message = {
|
|
207
|
+
id: `msg-${Date.now() + 1}-role-msg`,
|
|
208
|
+
from: 'role',
|
|
209
|
+
content: '',
|
|
210
|
+
type: 'conversation',
|
|
211
|
+
status: 'streaming',
|
|
212
|
+
timestamp: new Date().toISOString(),
|
|
213
|
+
};
|
|
214
|
+
addMessage(req.params.id, roleMsg, true);
|
|
215
|
+
|
|
216
|
+
res.json({ ok: true, sessionId: req.params.id });
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
/** POST /api/sessions/:id/reply — reply to awaiting_input execution via session */
|
|
220
|
+
sessionsRouter.post('/:id/reply', (req, res) => {
|
|
221
|
+
const session = getSession(req.params.id);
|
|
222
|
+
if (!session) {
|
|
223
|
+
res.status(404).json({ error: 'Session not found' });
|
|
224
|
+
return;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
const { message, responderRole, attachments } = req.body;
|
|
228
|
+
if (!message && (!attachments || attachments.length === 0)) {
|
|
229
|
+
res.status(400).json({ error: 'message or attachments required' });
|
|
230
|
+
return;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
const ceoMsg: Message = {
|
|
234
|
+
id: `msg-${Date.now()}-ceo-reply`,
|
|
235
|
+
from: 'ceo',
|
|
236
|
+
content: message ?? '',
|
|
237
|
+
type: 'conversation',
|
|
238
|
+
status: 'done',
|
|
239
|
+
timestamp: new Date().toISOString(),
|
|
240
|
+
attachments,
|
|
241
|
+
};
|
|
242
|
+
addMessage(req.params.id, ceoMsg);
|
|
243
|
+
|
|
244
|
+
const exec = executionManager.getActiveExecution(req.params.id);
|
|
245
|
+
let newExec;
|
|
246
|
+
|
|
247
|
+
if (exec) {
|
|
248
|
+
newExec = executionManager.continueSession(req.params.id, message ?? '(image attached)', responderRole);
|
|
249
|
+
if (!newExec) {
|
|
250
|
+
res.status(400).json({ error: 'Execution not in a replyable state' });
|
|
251
|
+
return;
|
|
252
|
+
}
|
|
253
|
+
} else {
|
|
254
|
+
const prevMessages = session.messages
|
|
255
|
+
.filter(m => m.id !== ceoMsg.id)
|
|
256
|
+
.slice(-6)
|
|
257
|
+
.map(m => `${m.from === 'ceo' ? 'CEO' : m.from.toUpperCase()}: ${m.content.slice(0, 500)}`)
|
|
258
|
+
.join('\n');
|
|
259
|
+
const task = prevMessages
|
|
260
|
+
? `[Conversation History]\n${prevMessages}\n\n[CEO Follow-up]\n${message ?? '(image attached)'}`
|
|
261
|
+
: (message ?? '(image attached)');
|
|
262
|
+
|
|
263
|
+
newExec = executionManager.startExecution({
|
|
264
|
+
type: 'assign',
|
|
265
|
+
roleId: session.roleId,
|
|
266
|
+
task,
|
|
267
|
+
sourceRole: responderRole ?? 'ceo',
|
|
268
|
+
sessionId: req.params.id,
|
|
269
|
+
attachments,
|
|
270
|
+
});
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const roleMsg: Message = {
|
|
274
|
+
id: `msg-${Date.now() + 1}-role-reply`,
|
|
275
|
+
from: 'role',
|
|
276
|
+
content: '',
|
|
277
|
+
type: 'conversation',
|
|
278
|
+
status: 'streaming',
|
|
279
|
+
timestamp: new Date().toISOString(),
|
|
280
|
+
};
|
|
281
|
+
addMessage(req.params.id, roleMsg, true);
|
|
282
|
+
|
|
283
|
+
if (session.waveId) {
|
|
284
|
+
updateFollowUpForReply(session.waveId, session.roleId, req.params.id);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
res.json({ ok: true, sessionId: req.params.id });
|
|
288
|
+
});
|