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 +3 -1
- package/src/api/src/engine/context-assembler.ts +45 -6
- package/src/api/src/engine/runners/claude-cli.ts +3 -9
- package/src/api/src/routes/execute.ts +38 -1
- package/src/api/src/routes/preferences.ts +16 -0
- package/src/api/src/services/activity-tracker.ts +21 -1
- package/src/api/src/services/job-manager.ts +17 -9
- package/src/api/src/services/preferences.ts +29 -17
- package/src/web/dist/assets/index-2aoEy16e.js +101 -0
- package/src/web/dist/assets/index-CShB32ow.css +1 -0
- package/src/web/dist/assets/{preview-app-CNfdunjF.js → preview-app-BO1ZIxzi.js} +1 -1
- package/src/web/dist/index.html +2 -2
- package/src/web/dist/assets/index-6v8I9154.css +0 -1
- package/src/web/dist/assets/index-gMRTdIIj.js +0 -101
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tycono",
|
|
3
|
-
"version": "0.1.
|
|
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
|
-
|
|
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
|
-
|
|
374
|
-
|
|
375
|
-
|
|
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
|
-
|
|
378
|
-
|
|
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
|
-
//
|
|
276
|
-
|
|
277
|
-
|
|
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
|
-
|
|
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
|
|
168
|
-
|
|
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
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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,
|