tycono 0.1.50 → 0.1.52

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tycono",
3
- "version": "0.1.50",
3
+ "version": "0.1.52",
4
4
  "description": "Build an AI company. Watch them work.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -21,6 +21,7 @@
21
21
  "dev:api": "npm run dev --prefix src/api",
22
22
  "dev:web": "npm run dev --prefix src/web",
23
23
  "build:web": "npm run build --prefix src/web",
24
+ "build:forge": "tsup --config tsup.forge.ts",
24
25
  "typecheck": "npm run typecheck:api && npm run typecheck:web",
25
26
  "typecheck:api": "cd src/api && npx tsc --noEmit",
26
27
  "typecheck:web": "cd src/web && npx tsc --noEmit",
@@ -41,6 +42,7 @@
41
42
  "@types/cors": "^2.8.17",
42
43
  "@types/express": "^5.0.0",
43
44
  "@types/node": "^22.13.4",
45
+ "tsup": "^8.5.1",
44
46
  "typescript": "^5.7.3"
45
47
  },
46
48
  "keywords": [
@@ -129,6 +129,8 @@ export function assembleContext(
129
129
  - If the directive is vague, focus on what YOUR ROLE can contribute. Don't try to cover everything.
130
130
  - Break ambiguous directives into concrete actions within your authority scope.
131
131
  - If you truly cannot determine what to do, state your interpretation and proceed with it.
132
+ - **If you have subordinates, your FIRST action should be decomposing the task and dispatching.** Do NOT attempt implementation yourself — delegate to the appropriate team member.
133
+ - Review the "Available Team Members" section to understand each subordinate's capabilities before dispatching.
132
134
 
133
135
  ## Efficiency
134
136
  - Read ONLY files directly relevant to your task. Do NOT explore the codebase broadly.
@@ -368,14 +370,51 @@ function buildDispatchSection(orgTree: OrgTree, roleId: string, subordinates: st
368
370
 
369
371
  const subInfo = subordinates.map((id) => {
370
372
  const sub = orgTree.nodes.get(id);
371
- const base = sub ? `- **${sub.name}** (\`${id}\`): ${sub.persona.split('\n')[0]}` : `- ${id}`;
373
+ if (!sub) return `- ${id} (unknown role)`;
374
+
375
+ const lines: string[] = [];
376
+
377
+ // Header: name, id, persona summary
372
378
  const st = teamStatus?.[id];
373
- if (st && st.status === 'working') {
374
- const taskHint = st.task ? `: "${st.task.slice(0, 60)}"` : '';
375
- return `${base} — **Working**${taskHint}`;
379
+ const status = st?.status === 'working'
380
+ ? `🔴 Working${st.task ? ` "${st.task.slice(0, 60)}"` : ''}`
381
+ : '🟢 Idle';
382
+ lines.push(`### ${sub.name} (\`${id}\`) — ${status}`);
383
+ lines.push(`> ${sub.persona.split('\n')[0]}`);
384
+
385
+ // Level & model
386
+ lines.push(`- **Level**: ${sub.level} | **Model**: ${sub.model ?? 'default'}`);
387
+
388
+ // Skills
389
+ if (sub.skills && sub.skills.length > 0) {
390
+ lines.push(`- **Skills**: ${sub.skills.join(', ')}`);
391
+ }
392
+
393
+ // Authority — what they can do autonomously
394
+ if (sub.authority.autonomous.length > 0) {
395
+ lines.push(`- **Can do**: ${sub.authority.autonomous.join(', ')}`);
396
+ }
397
+
398
+ // Knowledge scope — what they can read/write
399
+ if (sub.knowledge.reads.length > 0) {
400
+ lines.push(`- **Reads**: ${sub.knowledge.reads.join(', ')}`);
401
+ }
402
+ if (sub.knowledge.writes.length > 0) {
403
+ lines.push(`- **Writes**: ${sub.knowledge.writes.join(', ')}`);
376
404
  }
377
- return `${base} — Idle`;
378
- }).join('\n');
405
+
406
+ // Their own subordinates (for chain delegation visibility)
407
+ const grandchildren = orgTree.nodes.get(id)?.children ?? [];
408
+ if (grandchildren.length > 0) {
409
+ const gcNames = grandchildren.map(gc => {
410
+ const gcNode = orgTree.nodes.get(gc);
411
+ return gcNode ? `${gcNode.name} (${gc})` : gc;
412
+ });
413
+ lines.push(`- **Their reports**: ${gcNames.join(', ')}`);
414
+ }
415
+
416
+ return lines.join('\n');
417
+ }).join('\n\n');
379
418
 
380
419
  const exampleSubId = subordinates[0] ?? 'engineer';
381
420
 
@@ -272,15 +272,9 @@ export class ClaudeCliRunner implements ExecutionRunner {
272
272
  appendOutput: (t) => { output += t; },
273
273
  addToolCall: (name, input) => {
274
274
  toolCalls.push({ name, input });
275
- // Detect dispatch calls via Bash (dispatch bridge)
276
- if (name === 'Bash' && typeof input?.command === 'string') {
277
- const cmd = input.command;
278
- // Match: python3 "$DISPATCH_CMD" <roleId> "task" or dispatch <roleId> "task"
279
- const dispatchMatch = cmd.match(/(?:DISPATCH_CMD|dispatch(?:\.py)?)[^\n]*?\s+(\w+)\s+["'](.+?)["']/);
280
- if (dispatchMatch) {
281
- callbacks.onDispatch?.(dispatchMatch[1], dispatchMatch[2]);
282
- }
283
- }
275
+ // Dispatch detection removed child jobs created by the Python
276
+ // dispatch bridge script via POST /api/jobs with parentJobId.
277
+ // JobManager.startJob() now auto-emits dispatch:start on parent stream.
284
278
  },
285
279
  incrementTurn: () => { turnCount++; callbacks.onTurnComplete?.(turnCount); },
286
280
  recordTokens: (input, out) => {
@@ -158,6 +158,17 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
158
158
  parentJobId,
159
159
  });
160
160
  roleStatus.set(cRole, 'working');
161
+ // Clean up roleStatus when wave job completes — only if no other running jobs for same role
162
+ const sub = (event: { type: string }) => {
163
+ if (event.type === 'job:done' || event.type === 'job:error' || event.type === 'job:awaiting_input') {
164
+ job.stream.unsubscribe(sub);
165
+ const stillRunning = jobManager.listJobs({ status: 'running', roleId: cRole });
166
+ if (stillRunning.length === 0) {
167
+ roleStatus.set(cRole, 'idle');
168
+ }
169
+ }
170
+ };
171
+ job.stream.subscribe(sub);
161
172
  jobIds.push(job.id);
162
173
  }
163
174
 
@@ -187,6 +198,19 @@ function handleStartJob(body: Record<string, unknown>, res: ServerResponse): voi
187
198
  });
188
199
 
189
200
  roleStatus.set(roleId, 'working');
201
+
202
+ // Clean up roleStatus when job completes — only if no other running jobs for same role
203
+ const sub = (event: { type: string }) => {
204
+ if (event.type === 'job:done' || event.type === 'job:error' || event.type === 'job:awaiting_input') {
205
+ job.stream.unsubscribe(sub);
206
+ const stillRunning = jobManager.listJobs({ status: 'running', roleId });
207
+ if (stillRunning.length === 0) {
208
+ roleStatus.set(roleId, 'idle');
209
+ }
210
+ }
211
+ };
212
+ job.stream.subscribe(sub);
213
+
190
214
  jsonResponse(res, 200, { jobId: job.id });
191
215
  }
192
216
 
@@ -423,6 +447,7 @@ function startSSE(res: ServerResponse): void {
423
447
  'Connection': 'keep-alive',
424
448
  'X-Accel-Buffering': 'no',
425
449
  });
450
+ res.flushHeaders();
426
451
  }
427
452
 
428
453
  /** Start SSE heartbeat + timeout. Returns cleanup function. */
@@ -861,8 +886,20 @@ function handleSessionMessage(
861
886
  childSubscriptions.push({ job: childJob, subscriber });
862
887
  });
863
888
 
889
+ // Build team status from running jobs (same as JobManager pattern)
890
+ const teamStatus: Record<string, { status: string; task?: string }> = {};
891
+ for (const j of jobManager.listJobs({ status: 'running' })) {
892
+ teamStatus[j.roleId] = { status: 'working', task: j.task };
893
+ }
894
+ // Also include roleStatus for roles working via session (not tracked as jobs)
895
+ for (const [rid, status] of roleStatus) {
896
+ if (status === 'working' && rid !== roleId && !teamStatus[rid]) {
897
+ teamStatus[rid] = { status: 'working' };
898
+ }
899
+ }
900
+
864
901
  const handle = getRunner().execute(
865
- { companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model, attachments },
902
+ { companyRoot: COMPANY_ROOT, roleId, task: fullTask, sourceRole: 'ceo', orgTree, readOnly, model: orgTree.nodes.get(roleId)?.model, attachments, teamStatus },
866
903
  {
867
904
  onText: (text) => {
868
905
  roleMsg.content += text;
@@ -1,3 +1,4 @@
1
+ import crypto from 'node:crypto';
1
2
  import { Router, Request, Response, NextFunction } from 'express';
2
3
  import { COMPANY_ROOT } from '../services/file-reader.js';
3
4
  import { readPreferences, writePreferences, mergePreferences } from '../services/preferences.js';
@@ -21,7 +22,9 @@ preferencesRouter.put('/', (req: Request, res: Response, next: NextFunction) =>
21
22
  res.status(400).json({ error: 'Invalid preferences body' });
22
23
  return;
23
24
  }
25
+ const existing = readPreferences(COMPANY_ROOT);
24
26
  writePreferences(COMPANY_ROOT, {
27
+ instanceId: existing.instanceId, // preserve — never overwrite from client
25
28
  appearances: prefs.appearances ?? {},
26
29
  theme: prefs.theme ?? 'default',
27
30
  });
@@ -31,6 +34,19 @@ preferencesRouter.put('/', (req: Request, res: Response, next: NextFunction) =>
31
34
  }
32
35
  });
33
36
 
37
+ // POST /api/preferences/regenerate-token — regenerate instanceId
38
+ preferencesRouter.post('/regenerate-token', (_req: Request, res: Response, next: NextFunction) => {
39
+ try {
40
+ const current = readPreferences(COMPANY_ROOT);
41
+ const oldId = current.instanceId;
42
+ current.instanceId = crypto.randomUUID();
43
+ writePreferences(COMPANY_ROOT, current);
44
+ res.json({ ok: true, oldInstanceId: oldId, newInstanceId: current.instanceId });
45
+ } catch (err) {
46
+ next(err);
47
+ }
48
+ });
49
+
34
50
  // PATCH /api/preferences — partial merge
35
51
  preferencesRouter.patch('/', (req: Request, res: Response, next: NextFunction) => {
36
52
  try {
@@ -37,6 +37,7 @@ export function setActivity(roleId: string, task: string): void {
37
37
  recentOutput: '',
38
38
  };
39
39
  fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
40
+ invalidateCache();
40
41
  }
41
42
 
42
43
  export function updateActivity(roleId: string, output: string): void {
@@ -45,6 +46,7 @@ export function updateActivity(roleId: string, output: string): void {
45
46
  activity.updatedAt = new Date().toISOString();
46
47
  activity.recentOutput = output.slice(-500);
47
48
  fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
49
+ invalidateCache();
48
50
  }
49
51
 
50
52
  export function completeActivity(roleId: string): void {
@@ -53,6 +55,7 @@ export function completeActivity(roleId: string): void {
53
55
  activity.status = 'done';
54
56
  activity.updatedAt = new Date().toISOString();
55
57
  fs.writeFileSync(activityPath(roleId), JSON.stringify(activity, null, 2));
58
+ invalidateCache();
56
59
  }
57
60
 
58
61
  export function clearActivity(roleId: string): void {
@@ -60,6 +63,7 @@ export function clearActivity(roleId: string): void {
60
63
  if (fs.existsSync(filePath)) {
61
64
  fs.unlinkSync(filePath);
62
65
  }
66
+ invalidateCache();
63
67
  }
64
68
 
65
69
  export function getActivity(roleId: string): RoleActivity | null {
@@ -72,14 +76,30 @@ export function getActivity(roleId: string): RoleActivity | null {
72
76
  }
73
77
  }
74
78
 
79
+ /** Cached getAllActivities — avoids re-reading files within TTL window */
80
+ let _activitiesCache: RoleActivity[] | null = null;
81
+ let _activitiesCacheTs = 0;
82
+ const ACTIVITIES_CACHE_TTL = 500; // ms
83
+
75
84
  export function getAllActivities(): RoleActivity[] {
85
+ const now = Date.now();
86
+ if (_activitiesCache && now - _activitiesCacheTs < ACTIVITIES_CACHE_TTL) {
87
+ return _activitiesCache;
88
+ }
76
89
  ensureDir();
77
90
  const files = fs.readdirSync(activityDir()).filter(f => f.endsWith('.json'));
78
- return files.map(f => {
91
+ _activitiesCache = files.map(f => {
79
92
  try {
80
93
  return JSON.parse(fs.readFileSync(path.join(activityDir(), f), 'utf-8'));
81
94
  } catch {
82
95
  return null;
83
96
  }
84
97
  }).filter((a): a is RoleActivity => a !== null);
98
+ _activitiesCacheTs = now;
99
+ return _activitiesCache;
100
+ }
101
+
102
+ /** Invalidate cache after writes (setActivity, updateActivity, completeActivity) */
103
+ function invalidateCache(): void {
104
+ _activitiesCache = null;
85
105
  }
@@ -124,6 +124,20 @@ class JobManager {
124
124
  sourceRole: params.sourceRole ?? 'ceo',
125
125
  });
126
126
 
127
+ // If this job has a parent, emit dispatch:start on the parent's stream
128
+ // so the Wave Command Center can track this child job.
129
+ if (params.parentJobId) {
130
+ const parentJob = this.jobs.get(params.parentJobId);
131
+ if (parentJob) {
132
+ parentJob.childJobIds.push(jobId);
133
+ parentJob.stream.emit('dispatch:start', parentJob.roleId, {
134
+ targetRoleId: params.roleId,
135
+ task: params.task,
136
+ childJobId: jobId,
137
+ });
138
+ }
139
+ }
140
+
127
141
  // Set activity tracker
128
142
  setActivity(params.roleId, params.task);
129
143
 
@@ -164,21 +178,15 @@ class JobManager {
164
178
  });
165
179
  },
166
180
  onDispatch: (subRoleId, subTask) => {
167
- // Create child job for the dispatch
168
- const childJob = this.startJob({
181
+ // Create child job startJob() auto-emits dispatch:start
182
+ // on parent stream when parentJobId is set.
183
+ this.startJob({
169
184
  type: 'assign',
170
185
  roleId: subRoleId,
171
186
  task: subTask,
172
187
  sourceRole: params.roleId,
173
188
  parentJobId: jobId,
174
189
  });
175
- job.childJobIds.push(childJob.id);
176
-
177
- stream.emit('dispatch:start', params.roleId, {
178
- targetRoleId: subRoleId,
179
- task: subTask,
180
- childJobId: childJob.id,
181
- });
182
190
  },
183
191
  onTurnComplete: (turn) => {
184
192
  stream.emit('turn:complete', params.roleId, { turn });
@@ -4,6 +4,7 @@
4
4
  * 캐릭터 외모, 오피스 테마 등 사용자 설정을 서버 파일로 영속화한다.
5
5
  * company-config.ts의 readConfig/writeConfig 패턴을 따른다.
6
6
  */
7
+ import crypto from 'node:crypto';
7
8
  import fs from 'node:fs';
8
9
  import path from 'node:path';
9
10
 
@@ -46,6 +47,7 @@ export interface AddedFurniture {
46
47
  }
47
48
 
48
49
  export interface Preferences {
50
+ instanceId?: string; // anonymous persistent token — auto-generated on first read
49
51
  appearances: Record<string, CharacterAppearance>;
50
52
  theme: string;
51
53
  speech?: SpeechSettings;
@@ -64,25 +66,34 @@ function prefsPath(companyRoot: string): string {
64
66
  return path.join(companyRoot, CONFIG_DIR, PREFS_FILE);
65
67
  }
66
68
 
67
- /** Read preferences from .tycono/preferences.json. Returns defaults if missing. */
69
+ /** Read preferences from .tycono/preferences.json. Returns defaults if missing.
70
+ * Auto-generates instanceId on first access and persists it. */
68
71
  export function readPreferences(companyRoot: string): Preferences {
69
72
  const p = prefsPath(companyRoot);
70
- if (!fs.existsSync(p)) return { ...DEFAULT, appearances: {} };
71
- try {
72
- const data = JSON.parse(fs.readFileSync(p, 'utf-8'));
73
- return {
74
- appearances: data.appearances ?? {},
75
- theme: data.theme ?? 'default',
76
- speech: data.speech ?? undefined,
77
- language: data.language ?? undefined,
78
- furnitureOverrides: data.furnitureOverrides ?? undefined,
79
- deskOverrides: data.deskOverrides ?? undefined,
80
- removedFurniture: data.removedFurniture ?? undefined,
81
- addedFurniture: data.addedFurniture ?? undefined,
82
- };
83
- } catch {
84
- return { ...DEFAULT, appearances: {} };
73
+ let data: Record<string, unknown> = {};
74
+ if (fs.existsSync(p)) {
75
+ try { data = JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { /* use defaults */ }
85
76
  }
77
+
78
+ const prefs: Preferences = {
79
+ instanceId: (data.instanceId as string) ?? undefined,
80
+ appearances: (data.appearances as Record<string, CharacterAppearance>) ?? {},
81
+ theme: (data.theme as string) ?? 'default',
82
+ speech: (data.speech as SpeechSettings) ?? undefined,
83
+ language: (data.language as string) ?? undefined,
84
+ furnitureOverrides: (data.furnitureOverrides as Record<string, FurnitureOverride>) ?? undefined,
85
+ deskOverrides: (data.deskOverrides as Record<string, DeskOverride>) ?? undefined,
86
+ removedFurniture: (data.removedFurniture as string[]) ?? undefined,
87
+ addedFurniture: (data.addedFurniture as AddedFurniture[]) ?? undefined,
88
+ };
89
+
90
+ // Auto-generate instanceId on first access
91
+ if (!prefs.instanceId) {
92
+ prefs.instanceId = crypto.randomUUID();
93
+ writePreferences(companyRoot, prefs);
94
+ }
95
+
96
+ return prefs;
86
97
  }
87
98
 
88
99
  /** Write preferences to .tycono/preferences.json. Creates dir if needed. */
@@ -92,10 +103,11 @@ export function writePreferences(companyRoot: string, prefs: Preferences): void
92
103
  fs.writeFileSync(prefsPath(companyRoot), JSON.stringify(prefs, null, 2) + '\n');
93
104
  }
94
105
 
95
- /** Merge partial preferences into existing. */
106
+ /** Merge partial preferences into existing. instanceId is never overwritten by client. */
96
107
  export function mergePreferences(companyRoot: string, partial: Partial<Preferences>): Preferences {
97
108
  const current = readPreferences(companyRoot);
98
109
  const merged: Preferences = {
110
+ instanceId: current.instanceId, // preserve — never overwrite from client
99
111
  appearances: partial.appearances !== undefined
100
112
  ? { ...current.appearances, ...partial.appearances }
101
113
  : current.appearances,