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.
Files changed (84) hide show
  1. package/bin/cli.js +35 -0
  2. package/bin/server.ts +160 -0
  3. package/package.json +50 -0
  4. package/src/api/package.json +31 -0
  5. package/src/api/src/create-app.ts +90 -0
  6. package/src/api/src/create-server.ts +251 -0
  7. package/src/api/src/engine/agent-loop.ts +738 -0
  8. package/src/api/src/engine/authority-validator.ts +149 -0
  9. package/src/api/src/engine/context-assembler.ts +912 -0
  10. package/src/api/src/engine/index.ts +27 -0
  11. package/src/api/src/engine/knowledge-gate.ts +365 -0
  12. package/src/api/src/engine/llm-adapter.ts +304 -0
  13. package/src/api/src/engine/org-tree.ts +270 -0
  14. package/src/api/src/engine/role-lifecycle.ts +369 -0
  15. package/src/api/src/engine/runners/claude-cli.ts +796 -0
  16. package/src/api/src/engine/runners/direct-api.ts +66 -0
  17. package/src/api/src/engine/runners/index.ts +30 -0
  18. package/src/api/src/engine/runners/types.ts +95 -0
  19. package/src/api/src/engine/skill-template.ts +134 -0
  20. package/src/api/src/engine/tools/definitions.ts +201 -0
  21. package/src/api/src/engine/tools/executor.ts +611 -0
  22. package/src/api/src/routes/active-sessions.ts +134 -0
  23. package/src/api/src/routes/coins.ts +153 -0
  24. package/src/api/src/routes/company.ts +57 -0
  25. package/src/api/src/routes/cost.ts +141 -0
  26. package/src/api/src/routes/engine.ts +220 -0
  27. package/src/api/src/routes/execute.ts +1075 -0
  28. package/src/api/src/routes/git.ts +211 -0
  29. package/src/api/src/routes/knowledge.ts +378 -0
  30. package/src/api/src/routes/operations.ts +309 -0
  31. package/src/api/src/routes/preferences.ts +63 -0
  32. package/src/api/src/routes/presets.ts +123 -0
  33. package/src/api/src/routes/projects.ts +82 -0
  34. package/src/api/src/routes/quests.ts +41 -0
  35. package/src/api/src/routes/roles.ts +112 -0
  36. package/src/api/src/routes/save.ts +152 -0
  37. package/src/api/src/routes/sessions.ts +288 -0
  38. package/src/api/src/routes/setup.ts +437 -0
  39. package/src/api/src/routes/skills.ts +357 -0
  40. package/src/api/src/routes/speech.ts +959 -0
  41. package/src/api/src/routes/supervision.ts +136 -0
  42. package/src/api/src/routes/sync.ts +165 -0
  43. package/src/api/src/server.ts +59 -0
  44. package/src/api/src/services/activity-stream.ts +184 -0
  45. package/src/api/src/services/activity-tracker.ts +115 -0
  46. package/src/api/src/services/claude-md-manager.ts +94 -0
  47. package/src/api/src/services/company-config.ts +115 -0
  48. package/src/api/src/services/database.ts +77 -0
  49. package/src/api/src/services/digest-engine.ts +313 -0
  50. package/src/api/src/services/execution-manager.ts +1036 -0
  51. package/src/api/src/services/file-reader.ts +77 -0
  52. package/src/api/src/services/git-save.ts +614 -0
  53. package/src/api/src/services/job-manager.ts +16 -0
  54. package/src/api/src/services/knowledge-importer.ts +466 -0
  55. package/src/api/src/services/markdown-parser.ts +173 -0
  56. package/src/api/src/services/port-registry.ts +222 -0
  57. package/src/api/src/services/preferences.ts +150 -0
  58. package/src/api/src/services/preset-loader.ts +149 -0
  59. package/src/api/src/services/pricing.ts +34 -0
  60. package/src/api/src/services/scaffold.ts +546 -0
  61. package/src/api/src/services/session-store.ts +340 -0
  62. package/src/api/src/services/supervisor-heartbeat.ts +897 -0
  63. package/src/api/src/services/team-recommender.ts +382 -0
  64. package/src/api/src/services/token-ledger.ts +127 -0
  65. package/src/api/src/services/wave-messages.ts +194 -0
  66. package/src/api/src/services/wave-multiplexer.ts +356 -0
  67. package/src/api/src/services/wave-tracker.ts +359 -0
  68. package/src/api/src/utils/role-level.ts +31 -0
  69. package/src/core/scaffolder.ts +620 -0
  70. package/src/shared/types.ts +224 -0
  71. package/templates/CLAUDE.md.tmpl +239 -0
  72. package/templates/company.md.tmpl +17 -0
  73. package/templates/gitignore.tmpl +28 -0
  74. package/templates/roles.md.tmpl +8 -0
  75. package/templates/skills/_manifest.json +23 -0
  76. package/templates/skills/agent-browser/SKILL.md +159 -0
  77. package/templates/skills/agent-browser/meta.json +19 -0
  78. package/templates/skills/akb-linter/SKILL.md +125 -0
  79. package/templates/skills/akb-linter/meta.json +12 -0
  80. package/templates/skills/knowledge-gate/SKILL.md +120 -0
  81. package/templates/skills/knowledge-gate/meta.json +12 -0
  82. package/templates/teams/agency.json +58 -0
  83. package/templates/teams/research.json +58 -0
  84. 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
+ });