tycono 0.1.93-beta.2 → 0.1.93-beta.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/api/src/create-server.ts +2 -0
- package/src/api/src/engine/agent-loop.ts +52 -9
- package/src/api/src/engine/context-assembler.ts +59 -0
- package/src/api/src/engine/org-tree.ts +17 -0
- package/src/api/src/engine/runners/claude-cli.ts +157 -0
- package/src/api/src/engine/runners/direct-api.ts +2 -0
- package/src/api/src/engine/runners/types.ts +4 -0
- package/src/api/src/engine/tools/definitions.ts +55 -1
- package/src/api/src/engine/tools/executor.ts +163 -0
- package/src/api/src/routes/supervision.ts +136 -0
- package/src/api/src/services/digest-engine.ts +313 -0
- package/src/api/src/services/execution-manager.ts +6 -0
- package/src/shared/types.ts +2 -0
- package/src/web/dist/assets/index-CoIZS-E0.css +1 -0
- package/src/web/dist/assets/index-mJNkf7g0.js +115 -0
- package/src/web/dist/assets/{preview-app-CywDONM1.js → preview-app-BHNMBMSl.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-BoJAXuTo.js +0 -115
- package/src/web/dist/assets/index-rya2vj54.css +0 -1
package/package.json
CHANGED
|
@@ -32,6 +32,7 @@ import { skillsRouter } from './routes/skills.js';
|
|
|
32
32
|
import { questsRouter } from './routes/quests.js';
|
|
33
33
|
import { coinsRouter } from './routes/coins.js';
|
|
34
34
|
import { activeSessionsRouter } from './routes/active-sessions.js';
|
|
35
|
+
import { supervisionRouter } from './routes/supervision.js';
|
|
35
36
|
import { importKnowledge } from './services/knowledge-importer.js';
|
|
36
37
|
import { AnthropicProvider, type LLMProvider } from './engine/llm-adapter.js';
|
|
37
38
|
import { readConfig } from './services/company-config.js';
|
|
@@ -209,6 +210,7 @@ export function createExpressApp(): express.Application {
|
|
|
209
210
|
app.use('/api/quests', questsRouter);
|
|
210
211
|
app.use('/api/coins', coinsRouter);
|
|
211
212
|
app.use('/api/active-sessions', activeSessionsRouter);
|
|
213
|
+
app.use('/api/supervision', supervisionRouter);
|
|
212
214
|
|
|
213
215
|
app.get('/api/health', (_req, res) => {
|
|
214
216
|
res.json({ status: 'ok', companyRoot: COMPANY_ROOT });
|
|
@@ -37,6 +37,10 @@ export interface AgentConfig {
|
|
|
37
37
|
onTurnComplete?: (turn: number) => void;
|
|
38
38
|
/** Trace: emitted when system prompt is assembled */
|
|
39
39
|
onPromptAssembled?: (systemPrompt: string, userTask: string) => void;
|
|
40
|
+
/** Supervision: abort a running session */
|
|
41
|
+
onAbortSession?: (sessionId: string) => boolean;
|
|
42
|
+
/** Supervision: amend a running session with new instructions */
|
|
43
|
+
onAmendSession?: (sessionId: string, instruction: string) => boolean;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
export interface AgentResult {
|
|
@@ -51,19 +55,48 @@ export interface AgentResult {
|
|
|
51
55
|
|
|
52
56
|
/**
|
|
53
57
|
* Compress older messages to reduce token usage.
|
|
54
|
-
*
|
|
55
|
-
*
|
|
58
|
+
*
|
|
59
|
+
* SV-9 Enhancement: Zone-based compression for supervision sessions.
|
|
60
|
+
* - Zone A (pinned): first 2 messages (system prompt + original task + plan) — never compress
|
|
61
|
+
* - Zone B (rolling): middle messages — heartbeat ticks get aggressive compression
|
|
62
|
+
* - Zone C (recent): last 4 messages — preserve for LLM context
|
|
63
|
+
*
|
|
64
|
+
* Heartbeat-specific: consecutive quiet ticks are merged into a single line.
|
|
56
65
|
*/
|
|
57
66
|
function compressMessages(messages: LLMMessage[]): void {
|
|
58
67
|
if (messages.length <= 6) return;
|
|
59
68
|
|
|
60
|
-
//
|
|
69
|
+
// Zone A: first 2, Zone C: last 4
|
|
61
70
|
const keepHead = 2;
|
|
62
71
|
const keepTail = 4;
|
|
63
72
|
const compressRange = messages.slice(keepHead, messages.length - keepTail);
|
|
64
73
|
|
|
65
|
-
|
|
74
|
+
// Track consecutive quiet heartbeat ticks for merging
|
|
75
|
+
let quietTickStart = -1;
|
|
76
|
+
let quietTickCount = 0;
|
|
77
|
+
|
|
78
|
+
for (let idx = 0; idx < compressRange.length; idx++) {
|
|
79
|
+
const msg = compressRange[idx];
|
|
80
|
+
|
|
66
81
|
if (typeof msg.content === 'string') {
|
|
82
|
+
// Check if this is a heartbeat quiet tick result
|
|
83
|
+
const isQuietTick = msg.content.includes('sessions progressing normally') && msg.content.includes('No anomalies');
|
|
84
|
+
|
|
85
|
+
if (isQuietTick) {
|
|
86
|
+
quietTickCount++;
|
|
87
|
+
if (quietTickStart === -1) quietTickStart = idx;
|
|
88
|
+
|
|
89
|
+
// Merge consecutive quiet ticks
|
|
90
|
+
if (quietTickCount > 1) {
|
|
91
|
+
msg.content = `[Quiet ticks merged: ${quietTickCount} ticks, no anomalies]`;
|
|
92
|
+
}
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// Reset quiet tick counter on non-quiet content
|
|
97
|
+
quietTickStart = -1;
|
|
98
|
+
quietTickCount = 0;
|
|
99
|
+
|
|
67
100
|
// Truncate long text content
|
|
68
101
|
if (msg.content.length > 500) {
|
|
69
102
|
msg.content = msg.content.slice(0, 300) + '\n\n[... compressed ...]';
|
|
@@ -73,8 +106,13 @@ function compressMessages(messages: LLMMessage[]): void {
|
|
|
73
106
|
const block = msg.content[i] as Record<string, unknown>;
|
|
74
107
|
if (block.type === 'tool_result') {
|
|
75
108
|
const content = typeof block.content === 'string' ? block.content : '';
|
|
76
|
-
|
|
77
|
-
|
|
109
|
+
|
|
110
|
+
// Heartbeat digest results: compress more aggressively
|
|
111
|
+
const isDigest = content.includes('Supervision Digest') || content.includes('sessions progressing normally');
|
|
112
|
+
const maxLen = isDigest ? 150 : 300;
|
|
113
|
+
|
|
114
|
+
if (content.length > maxLen) {
|
|
115
|
+
block.content = content.slice(0, maxLen - 50) + '\n[... compressed, was ' + content.length + ' chars]';
|
|
78
116
|
}
|
|
79
117
|
} else if (block.type === 'text' && typeof block.text === 'string' && block.text.length > 500) {
|
|
80
118
|
block.text = (block.text as string).slice(0, 300) + '\n[... compressed ...]';
|
|
@@ -132,7 +170,9 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
132
170
|
// 2. Determine tools
|
|
133
171
|
const subordinates = getSubordinates(orgTree, roleId);
|
|
134
172
|
const hasBash = !readOnly && !!config.codeRoot;
|
|
135
|
-
const
|
|
173
|
+
const node = orgTree.nodes.get(roleId);
|
|
174
|
+
const heartbeatEnabled = node?.heartbeat?.enabled === true && subordinates.length > 0;
|
|
175
|
+
const tools = getToolsForRole(subordinates.length > 0, readOnly, hasBash, heartbeatEnabled);
|
|
136
176
|
|
|
137
177
|
// 3. Set up tool executor
|
|
138
178
|
const toolExecOptions: ToolExecutorOptions = {
|
|
@@ -140,7 +180,10 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
140
180
|
roleId,
|
|
141
181
|
orgTree,
|
|
142
182
|
codeRoot: config.codeRoot,
|
|
183
|
+
sessionId: config.sessionId,
|
|
143
184
|
onToolExec,
|
|
185
|
+
onAbortSession: config.onAbortSession,
|
|
186
|
+
onAmendSession: config.onAmendSession,
|
|
144
187
|
onDispatch: async (targetRoleId: string, subTask: string) => {
|
|
145
188
|
// Recursive dispatch — validate, then run sub-agent
|
|
146
189
|
const authResult = validateDispatch(orgTree, roleId, targetRoleId);
|
|
@@ -318,9 +361,9 @@ export async function runAgentLoop(config: AgentConfig): Promise<AgentResult> {
|
|
|
318
361
|
);
|
|
319
362
|
|
|
320
363
|
// EG-004: Parallel tool execution for independent tools
|
|
321
|
-
// dispatch/consult run sequentially (recursive agent calls)
|
|
364
|
+
// dispatch/consult/heartbeat run sequentially (recursive agent calls / blocking)
|
|
322
365
|
// All other tools run in parallel via Promise.all()
|
|
323
|
-
const sequentialTools = new Set(['dispatch', 'consult']);
|
|
366
|
+
const sequentialTools = new Set(['dispatch', 'consult', 'heartbeat_watch']);
|
|
324
367
|
const parallelCalls = toolCalls.filter(tc => !sequentialTools.has(tc.name));
|
|
325
368
|
const sequentialCalls = toolCalls.filter(tc => sequentialTools.has(tc.name));
|
|
326
369
|
|
|
@@ -132,6 +132,12 @@ Use the code repository path for all source code work (reading, writing, buildin
|
|
|
132
132
|
subordinates = subordinates.filter(id => options.targetRoles!.includes(id));
|
|
133
133
|
}
|
|
134
134
|
|
|
135
|
+
// Supervision prompt (SV-11, SV-12: C-Level heartbeat mode)
|
|
136
|
+
const heartbeatEnabled = node.heartbeat?.enabled === true;
|
|
137
|
+
if (heartbeatEnabled && subordinates.length > 0) {
|
|
138
|
+
sections.push(buildSupervisionSection(node));
|
|
139
|
+
}
|
|
140
|
+
|
|
135
141
|
// Dispatch 도구 안내 (하위 Role이 있는 경우)
|
|
136
142
|
if (subordinates.length > 0) {
|
|
137
143
|
sections.push(buildDispatchSection(orgTree, roleId, subordinates, options?.teamStatus));
|
|
@@ -697,6 +703,59 @@ Your final report MUST include a **Change Summary** with files changed and commi
|
|
|
697
703
|
return section;
|
|
698
704
|
}
|
|
699
705
|
|
|
706
|
+
function buildSupervisionSection(node: OrgNode): string {
|
|
707
|
+
const hb = node.heartbeat ?? { enabled: true, intervalSec: 120, maxTicks: 60 };
|
|
708
|
+
return `# Supervision Mode (Heartbeat)
|
|
709
|
+
|
|
710
|
+
⛔ **When you dispatch subordinates, you MUST enter supervision mode using heartbeat_watch.**
|
|
711
|
+
⛔ **Do NOT use sleep+curl polling. heartbeat_watch blocks server-side at zero cost.**
|
|
712
|
+
|
|
713
|
+
## Supervision Protocol
|
|
714
|
+
|
|
715
|
+
1. **Dispatch** subordinates with clear task descriptions
|
|
716
|
+
2. **Call heartbeat_watch** with the returned session IDs:
|
|
717
|
+
\`heartbeat_watch(sessionIds=[...], durationSec=${hb.intervalSec})\`
|
|
718
|
+
3. **Analyze the digest** against your plan:
|
|
719
|
+
- On track → call heartbeat_watch again (keep watching)
|
|
720
|
+
- Off track → \`amend_session(sessionId, instruction)\` to course-correct
|
|
721
|
+
- Seriously wrong → \`abort_session(sessionId)\` + re-dispatch with different instructions
|
|
722
|
+
- Need peer input → \`consult(peer_role_id, question)\`
|
|
723
|
+
- All done → compile results and report to your superior
|
|
724
|
+
4. **Repeat** heartbeat_watch until all subordinates complete
|
|
725
|
+
|
|
726
|
+
## Available Supervision Tools
|
|
727
|
+
|
|
728
|
+
| Tool | When to Use |
|
|
729
|
+
|------|-------------|
|
|
730
|
+
| \`heartbeat_watch\` | Watch subordinate sessions (blocks ${hb.intervalSec}s, $0 LLM cost) |
|
|
731
|
+
| \`amend_session\` | Inject new instructions into a running session |
|
|
732
|
+
| \`abort_session\` | Kill a session that's going wrong |
|
|
733
|
+
| \`consult\` | Ask a peer C-Level for their perspective |
|
|
734
|
+
|
|
735
|
+
## Digest Response
|
|
736
|
+
|
|
737
|
+
heartbeat_watch returns a digest with:
|
|
738
|
+
- **Significance score** (0-10): How much attention this tick needs
|
|
739
|
+
- **Anomalies**: Errors, stalls (3min+), sessions awaiting input
|
|
740
|
+
- **Per-session activity**: What each subordinate has been doing
|
|
741
|
+
- **Peer activity** (if peers are also in supervision mode)
|
|
742
|
+
|
|
743
|
+
Quiet ticks (score 0-1) return a single line: "All N sessions progressing normally."
|
|
744
|
+
|
|
745
|
+
## Budget
|
|
746
|
+
|
|
747
|
+
- Max ticks: ${hb.maxTicks} (${Math.round(hb.maxTicks * hb.intervalSec / 60)} minutes total)
|
|
748
|
+
- Quiet tick cost: ~$0.001 (minimal LLM analysis)
|
|
749
|
+
- Alert tick cost: ~$0.02-0.05 (intervention decision)
|
|
750
|
+
|
|
751
|
+
## ⛔ Anti-Patterns
|
|
752
|
+
|
|
753
|
+
- ❌ Using \`bash_execute\` with sleep/curl to poll — use heartbeat_watch instead
|
|
754
|
+
- ❌ Calling \`--check\` in a loop — heartbeat_watch handles this automatically
|
|
755
|
+
- ❌ Ignoring digest anomalies — always address errors and stalls
|
|
756
|
+
- ❌ Not re-watching after a quiet tick — keep the loop going until all done`;
|
|
757
|
+
}
|
|
758
|
+
|
|
700
759
|
function buildConsultSection(orgTree: OrgTree, roleId: string): string | null {
|
|
701
760
|
// Build list of roles this agent can consult
|
|
702
761
|
const consultable: string[] = [];
|
|
@@ -21,6 +21,12 @@ export interface RoleSource {
|
|
|
21
21
|
upstream_version?: string;
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
export interface HeartbeatConfig {
|
|
25
|
+
enabled: boolean;
|
|
26
|
+
intervalSec: number; // default 120
|
|
27
|
+
maxTicks: number; // default 60
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
export interface OrgNode {
|
|
25
31
|
id: string;
|
|
26
32
|
name: string;
|
|
@@ -34,6 +40,7 @@ export interface OrgNode {
|
|
|
34
40
|
skills?: string[];
|
|
35
41
|
model?: string;
|
|
36
42
|
source?: RoleSource;
|
|
43
|
+
heartbeat?: HeartbeatConfig;
|
|
37
44
|
}
|
|
38
45
|
|
|
39
46
|
export interface OrgTree {
|
|
@@ -69,6 +76,11 @@ interface RawRoleYaml {
|
|
|
69
76
|
forked_at?: string;
|
|
70
77
|
upstream_version?: string;
|
|
71
78
|
};
|
|
79
|
+
heartbeat?: {
|
|
80
|
+
enabled?: boolean;
|
|
81
|
+
intervalSec?: number;
|
|
82
|
+
maxTicks?: number;
|
|
83
|
+
};
|
|
72
84
|
}
|
|
73
85
|
|
|
74
86
|
/* ─── Build ──────────────────────────────────── */
|
|
@@ -128,6 +140,11 @@ export function buildOrgTree(companyRoot: string): OrgTree {
|
|
|
128
140
|
forked_at: raw.source.forked_at,
|
|
129
141
|
upstream_version: raw.source.upstream_version,
|
|
130
142
|
} : undefined,
|
|
143
|
+
heartbeat: raw.heartbeat ? {
|
|
144
|
+
enabled: raw.heartbeat.enabled ?? false,
|
|
145
|
+
intervalSec: raw.heartbeat.intervalSec ?? 120,
|
|
146
|
+
maxTicks: raw.heartbeat.maxTicks ?? 60,
|
|
147
|
+
} : undefined,
|
|
131
148
|
};
|
|
132
149
|
tree.nodes.set(node.id, node);
|
|
133
150
|
} catch {
|
|
@@ -249,6 +249,152 @@ log(f'')
|
|
|
249
249
|
log(f'Poll every 10s until status is DONE.')
|
|
250
250
|
`;
|
|
251
251
|
|
|
252
|
+
/* ─── Supervision Bridge Script (Python3) — SV-14 ────── */
|
|
253
|
+
|
|
254
|
+
const SUPERVISION_SCRIPT = `#!/usr/bin/env python3
|
|
255
|
+
"""supervision-bridge: C-Level이 부하 세션을 감시하는 브릿지 스크립트.
|
|
256
|
+
|
|
257
|
+
사용법:
|
|
258
|
+
supervision watch ses-001,ses-002 --duration 120 — Long-poll watch (blocking)
|
|
259
|
+
supervision peers --wave xxx --role cto — Peer session discovery
|
|
260
|
+
supervision abort ses-001 --reason "Wrong direction" — Abort session
|
|
261
|
+
supervision amend ses-001 "New instructions here" — Amend session
|
|
262
|
+
|
|
263
|
+
환경변수:
|
|
264
|
+
DISPATCH_API_URL — API 서버 URL (default: http://localhost:3001)
|
|
265
|
+
"""
|
|
266
|
+
import sys, os, json, urllib.request, urllib.error
|
|
267
|
+
sys.stdout.reconfigure(line_buffering=True)
|
|
268
|
+
|
|
269
|
+
api = os.environ.get('DISPATCH_API_URL', 'http://localhost:3001')
|
|
270
|
+
|
|
271
|
+
def log(msg):
|
|
272
|
+
print(msg, flush=True)
|
|
273
|
+
|
|
274
|
+
if len(sys.argv) < 2:
|
|
275
|
+
log('Usage: supervision <watch|peers|abort|amend> [args...]')
|
|
276
|
+
sys.exit(1)
|
|
277
|
+
|
|
278
|
+
cmd = sys.argv[1]
|
|
279
|
+
|
|
280
|
+
if cmd == 'watch':
|
|
281
|
+
sessions = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
282
|
+
duration = '120'
|
|
283
|
+
alert_on = 'msg:done,msg:error'
|
|
284
|
+
i = 3
|
|
285
|
+
while i < len(sys.argv):
|
|
286
|
+
if sys.argv[i] == '--duration' and i + 1 < len(sys.argv):
|
|
287
|
+
duration = sys.argv[i + 1]
|
|
288
|
+
i += 2
|
|
289
|
+
elif sys.argv[i] == '--alert-on' and i + 1 < len(sys.argv):
|
|
290
|
+
alert_on = sys.argv[i + 1]
|
|
291
|
+
i += 2
|
|
292
|
+
else:
|
|
293
|
+
i += 1
|
|
294
|
+
|
|
295
|
+
if not sessions:
|
|
296
|
+
log('Error: session IDs required (comma-separated)')
|
|
297
|
+
sys.exit(1)
|
|
298
|
+
|
|
299
|
+
url = f'{api}/api/supervision/watch?sessions={sessions}&duration={duration}&alertOn={alert_on}'
|
|
300
|
+
try:
|
|
301
|
+
resp = json.loads(urllib.request.urlopen(url, timeout=int(duration) + 10).read())
|
|
302
|
+
log(resp.get('text', '(no digest)'))
|
|
303
|
+
if resp.get('anomalies'):
|
|
304
|
+
log(f'\\nAnomalies: {len(resp["anomalies"])}')
|
|
305
|
+
for a in resp['anomalies']:
|
|
306
|
+
log(f' [{a["type"]}] {a["message"]}')
|
|
307
|
+
except Exception as e:
|
|
308
|
+
log(f'ERROR: {e}')
|
|
309
|
+
sys.exit(0)
|
|
310
|
+
|
|
311
|
+
elif cmd == 'peers':
|
|
312
|
+
wave_id = ''
|
|
313
|
+
role_id = ''
|
|
314
|
+
i = 2
|
|
315
|
+
while i < len(sys.argv):
|
|
316
|
+
if sys.argv[i] == '--wave' and i + 1 < len(sys.argv):
|
|
317
|
+
wave_id = sys.argv[i + 1]
|
|
318
|
+
i += 2
|
|
319
|
+
elif sys.argv[i] == '--role' and i + 1 < len(sys.argv):
|
|
320
|
+
role_id = sys.argv[i + 1]
|
|
321
|
+
i += 2
|
|
322
|
+
else:
|
|
323
|
+
i += 1
|
|
324
|
+
|
|
325
|
+
if not wave_id or not role_id:
|
|
326
|
+
log('Usage: supervision peers --wave <waveId> --role <roleId>')
|
|
327
|
+
sys.exit(1)
|
|
328
|
+
|
|
329
|
+
try:
|
|
330
|
+
url = f'{api}/api/supervision/peers?waveId={wave_id}&roleId={role_id}'
|
|
331
|
+
resp = json.loads(urllib.request.urlopen(url, timeout=10).read())
|
|
332
|
+
peers = resp.get('peers', [])
|
|
333
|
+
if not peers:
|
|
334
|
+
log('No peer C-Level sessions found in this wave.')
|
|
335
|
+
else:
|
|
336
|
+
for p in peers:
|
|
337
|
+
log(f'[{p["roleId"]}] {p["sessionId"]} — {p["status"]} — {p["task"][:80]}')
|
|
338
|
+
except Exception as e:
|
|
339
|
+
log(f'ERROR: {e}')
|
|
340
|
+
sys.exit(0)
|
|
341
|
+
|
|
342
|
+
elif cmd == 'abort':
|
|
343
|
+
session_id = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
344
|
+
reason = 'Aborted by supervisor'
|
|
345
|
+
i = 3
|
|
346
|
+
while i < len(sys.argv):
|
|
347
|
+
if sys.argv[i] == '--reason' and i + 1 < len(sys.argv):
|
|
348
|
+
reason = sys.argv[i + 1]
|
|
349
|
+
i += 2
|
|
350
|
+
else:
|
|
351
|
+
i += 1
|
|
352
|
+
|
|
353
|
+
if not session_id:
|
|
354
|
+
log('Usage: supervision abort <sessionId> [--reason "..."]')
|
|
355
|
+
sys.exit(1)
|
|
356
|
+
|
|
357
|
+
try:
|
|
358
|
+
body = json.dumps({'sessionId': session_id, 'reason': reason}).encode()
|
|
359
|
+
req = urllib.request.Request(f'{api}/api/jobs/{session_id}', method='DELETE')
|
|
360
|
+
urllib.request.urlopen(req, timeout=10)
|
|
361
|
+
log(f'Session {session_id} aborted. Reason: {reason}')
|
|
362
|
+
except Exception as e:
|
|
363
|
+
log(f'ERROR: {e}')
|
|
364
|
+
sys.exit(0)
|
|
365
|
+
|
|
366
|
+
elif cmd == 'amend':
|
|
367
|
+
session_id = sys.argv[2] if len(sys.argv) > 2 else ''
|
|
368
|
+
instruction = ' '.join(sys.argv[3:]) if len(sys.argv) > 3 else ''
|
|
369
|
+
|
|
370
|
+
if not session_id or not instruction:
|
|
371
|
+
log('Usage: supervision amend <sessionId> "<instruction>"')
|
|
372
|
+
sys.exit(1)
|
|
373
|
+
|
|
374
|
+
# Amend uses continue-session with amended context
|
|
375
|
+
body = json.dumps({
|
|
376
|
+
'response': f'[SUPERVISION AMENDMENT] {instruction}',
|
|
377
|
+
'responderRole': os.environ.get('DISPATCH_SOURCE_ROLE', 'ceo'),
|
|
378
|
+
}).encode()
|
|
379
|
+
|
|
380
|
+
try:
|
|
381
|
+
req = urllib.request.Request(
|
|
382
|
+
f'{api}/api/exec/session/{session_id}/message',
|
|
383
|
+
body,
|
|
384
|
+
{'Content-Type': 'application/json'},
|
|
385
|
+
)
|
|
386
|
+
resp = json.loads(urllib.request.urlopen(req, timeout=10).read())
|
|
387
|
+
log(f'Session {session_id} amended with new instructions.')
|
|
388
|
+
except Exception as e:
|
|
389
|
+
log(f'ERROR: {e}')
|
|
390
|
+
sys.exit(0)
|
|
391
|
+
|
|
392
|
+
else:
|
|
393
|
+
log(f'Unknown command: {cmd}')
|
|
394
|
+
log('Usage: supervision <watch|peers|abort|amend> [args...]')
|
|
395
|
+
sys.exit(1)
|
|
396
|
+
`;
|
|
397
|
+
|
|
252
398
|
/* ─── Claude CLI Runner ──────────────────────── */
|
|
253
399
|
|
|
254
400
|
/**
|
|
@@ -303,6 +449,12 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
303
449
|
const consultScript = path.join(tmpDir, `consult-${roleId}-${Date.now()}.py`);
|
|
304
450
|
fs.writeFileSync(consultScript, CONSULT_SCRIPT, { mode: 0o755 });
|
|
305
451
|
|
|
452
|
+
// Supervision Bridge — for C-Level roles with subordinates + heartbeat enabled
|
|
453
|
+
const supervisionScript = path.join(tmpDir, `supervision-${roleId}-${Date.now()}.py`);
|
|
454
|
+
if (subordinates.length > 0) {
|
|
455
|
+
fs.writeFileSync(supervisionScript, SUPERVISION_SCRIPT, { mode: 0o755 });
|
|
456
|
+
}
|
|
457
|
+
|
|
306
458
|
// 5. Playwright MCP 설정 — 각 runner 인스턴스가 독립 브라우저 사용
|
|
307
459
|
const runnerOutputDir = path.join(tmpDir, `playwright-${roleId}-${Date.now()}`);
|
|
308
460
|
fs.mkdirSync(runnerOutputDir, { recursive: true });
|
|
@@ -361,6 +513,9 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
361
513
|
cleanEnv.DISPATCH_CMD = dispatchScript;
|
|
362
514
|
cleanEnv.CONSULT_CMD = consultScript;
|
|
363
515
|
cleanEnv.CONSULT_SOURCE_ROLE = roleId;
|
|
516
|
+
if (subordinates.length > 0) {
|
|
517
|
+
cleanEnv.SUPERVISION_CMD = supervisionScript;
|
|
518
|
+
}
|
|
364
519
|
|
|
365
520
|
const modelName = config.model ?? 'claude-sonnet-4-5';
|
|
366
521
|
// Use codeRoot as cwd — auto-creates ../{name}-code/ if not configured
|
|
@@ -499,6 +654,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
499
654
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
500
655
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
501
656
|
try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
|
|
657
|
+
try { fs.unlinkSync(supervisionScript); } catch { /* ignore */ }
|
|
502
658
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
503
659
|
|
|
504
660
|
// 비정상 종료 시에도 결과 반환 (output이 있을 수 있으므로)
|
|
@@ -517,6 +673,7 @@ export class ClaudeCliRunner implements ExecutionRunner {
|
|
|
517
673
|
try { fs.unlinkSync(promptFile); } catch { /* ignore */ }
|
|
518
674
|
try { fs.unlinkSync(dispatchScript); } catch { /* ignore */ }
|
|
519
675
|
try { fs.unlinkSync(consultScript); } catch { /* ignore */ }
|
|
676
|
+
try { fs.unlinkSync(supervisionScript); } catch { /* ignore */ }
|
|
520
677
|
try { fs.rmSync(runnerOutputDir, { recursive: true, force: true }); } catch { /* ignore */ }
|
|
521
678
|
reject(err);
|
|
522
679
|
});
|
|
@@ -48,6 +48,8 @@ export class DirectApiRunner implements ExecutionRunner {
|
|
|
48
48
|
onConsult: (roleId, question) => callbacks.onConsult?.(roleId, question),
|
|
49
49
|
onTurnComplete: (turn) => callbacks.onTurnComplete?.(turn),
|
|
50
50
|
onPromptAssembled: (systemPrompt, userTask) => callbacks.onPromptAssembled?.(systemPrompt, userTask),
|
|
51
|
+
onAbortSession: config.onAbortSession,
|
|
52
|
+
onAmendSession: config.onAmendSession,
|
|
51
53
|
}).then((agentResult): RunnerResult => ({
|
|
52
54
|
output: agentResult.output,
|
|
53
55
|
turns: agentResult.turns,
|
|
@@ -45,6 +45,10 @@ export interface RunnerConfig {
|
|
|
45
45
|
codeRoot?: string;
|
|
46
46
|
/** PSM-004: Environment variables to inject (e.g., port assignments) */
|
|
47
47
|
env?: Record<string, string>;
|
|
48
|
+
/** SV-7: Supervision — abort a running session */
|
|
49
|
+
onAbortSession?: (sessionId: string) => boolean;
|
|
50
|
+
/** SV-6: Supervision — amend a running session */
|
|
51
|
+
onAmendSession?: (sessionId: string, instruction: string) => boolean;
|
|
48
52
|
}
|
|
49
53
|
|
|
50
54
|
/* ─── Callbacks ───────────────────────────────── */
|
|
@@ -106,6 +106,54 @@ export const BASH_TOOL: ToolDefinition = {
|
|
|
106
106
|
},
|
|
107
107
|
};
|
|
108
108
|
|
|
109
|
+
/**
|
|
110
|
+
* Supervision 도구 — C-Level에게만 제공 (부하/동료 세션 감시)
|
|
111
|
+
*/
|
|
112
|
+
export const HEARTBEAT_WATCH_TOOL: ToolDefinition = {
|
|
113
|
+
name: 'heartbeat_watch',
|
|
114
|
+
description: 'Block and watch activity streams of subordinates (or peers). Returns a digest of events after the specified duration or when an alert event occurs. Use this to supervise running dispatches at zero LLM cost during the wait period.',
|
|
115
|
+
input_schema: {
|
|
116
|
+
type: 'object',
|
|
117
|
+
properties: {
|
|
118
|
+
sessionIds: { type: 'array', items: { type: 'string' }, description: 'Session IDs to watch (subordinates or peers)' },
|
|
119
|
+
durationSec: { type: 'number', description: 'Watch duration in seconds (default 120, max 300)', default: 120 },
|
|
120
|
+
alertOn: {
|
|
121
|
+
type: 'array',
|
|
122
|
+
items: { type: 'string' },
|
|
123
|
+
description: 'Event types that trigger early return (default: msg:done, msg:error)',
|
|
124
|
+
default: ['msg:done', 'msg:error'],
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
required: ['sessionIds'],
|
|
128
|
+
},
|
|
129
|
+
};
|
|
130
|
+
|
|
131
|
+
export const AMEND_SESSION_TOOL: ToolDefinition = {
|
|
132
|
+
name: 'amend_session',
|
|
133
|
+
description: 'Send additional instructions to a running subordinate session. The instructions will be injected at the next turn boundary. Use when a subordinate is going in the wrong direction and needs course correction.',
|
|
134
|
+
input_schema: {
|
|
135
|
+
type: 'object',
|
|
136
|
+
properties: {
|
|
137
|
+
sessionId: { type: 'string', description: 'Target session ID to amend' },
|
|
138
|
+
instruction: { type: 'string', description: 'Additional instruction to inject into the session' },
|
|
139
|
+
},
|
|
140
|
+
required: ['sessionId', 'instruction'],
|
|
141
|
+
},
|
|
142
|
+
};
|
|
143
|
+
|
|
144
|
+
export const ABORT_SESSION_TOOL: ToolDefinition = {
|
|
145
|
+
name: 'abort_session',
|
|
146
|
+
description: 'Abort a running subordinate session immediately. Use when the subordinate is clearly doing the wrong thing and needs to be stopped. You can re-dispatch with different instructions afterwards.',
|
|
147
|
+
input_schema: {
|
|
148
|
+
type: 'object',
|
|
149
|
+
properties: {
|
|
150
|
+
sessionId: { type: 'string', description: 'Target session ID to abort' },
|
|
151
|
+
reason: { type: 'string', description: 'Reason for aborting (logged in activity stream)' },
|
|
152
|
+
},
|
|
153
|
+
required: ['sessionId'],
|
|
154
|
+
},
|
|
155
|
+
};
|
|
156
|
+
|
|
109
157
|
/**
|
|
110
158
|
* 상담 도구 — 모든 Role에게 제공 (동료/상관/부하에게 질문)
|
|
111
159
|
*/
|
|
@@ -124,8 +172,9 @@ export const CONSULT_TOOL: ToolDefinition = {
|
|
|
124
172
|
|
|
125
173
|
/**
|
|
126
174
|
* Role에 따른 도구 목록 반환
|
|
175
|
+
* @param heartbeatEnabled - C-Level supervision mode enabled (provides heartbeat_watch, amend_session, abort_session)
|
|
127
176
|
*/
|
|
128
|
-
export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean, hasBash = false): ToolDefinition[] {
|
|
177
|
+
export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean, hasBash = false, heartbeatEnabled = false): ToolDefinition[] {
|
|
129
178
|
if (readOnly) {
|
|
130
179
|
return [...READ_TOOLS];
|
|
131
180
|
}
|
|
@@ -138,6 +187,11 @@ export function getToolsForRole(hasSubordinates: boolean, readOnly: boolean, has
|
|
|
138
187
|
|
|
139
188
|
if (hasSubordinates) {
|
|
140
189
|
tools.push(DISPATCH_TOOL);
|
|
190
|
+
|
|
191
|
+
// Supervision tools — only for roles with subordinates AND heartbeat enabled
|
|
192
|
+
if (heartbeatEnabled) {
|
|
193
|
+
tools.push(HEARTBEAT_WATCH_TOOL, AMEND_SESSION_TOOL, ABORT_SESSION_TOOL);
|
|
194
|
+
}
|
|
141
195
|
}
|
|
142
196
|
|
|
143
197
|
return tools;
|