@aion0/forge 0.5.22 → 0.5.24

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.
@@ -1129,7 +1129,7 @@ async function handleDocs(chatId: number, input: string) {
1129
1129
  }
1130
1130
 
1131
1131
  // /docs — show summary of latest Claude session for docs
1132
- const hash = docRoot.replace(/\//g, '-');
1132
+ const hash = docRoot.replace(/[^a-zA-Z0-9]/g, '-');
1133
1133
  const claudeDir = join(getHome(), '.claude', 'projects', hash);
1134
1134
 
1135
1135
  if (!existsSync(claudeDir)) {
@@ -41,6 +41,30 @@ import {
41
41
  } from './smith-memory';
42
42
  import { getFixedSession } from '../project-sessions';
43
43
 
44
+ // ─── Workspace Topology Cache ────────────────────────────
45
+
46
+ export interface TopoAgent {
47
+ id: string;
48
+ label: string;
49
+ icon: string;
50
+ role: string; // full role text
51
+ roleSummary: string; // first line, ≤150 chars
52
+ primary: boolean;
53
+ dependsOn: string[]; // agent labels (not IDs)
54
+ dependsOnIds: string[];
55
+ workDir: string;
56
+ outputs: string[];
57
+ steps: string[]; // step labels
58
+ smithStatus: string;
59
+ taskStatus: string;
60
+ }
61
+
62
+ export interface WorkspaceTopo {
63
+ agents: TopoAgent[];
64
+ flow: string; // "Lead → Engineer → QA → Reviewer"
65
+ updatedAt: number;
66
+ }
67
+
44
68
  // ─── Orchestrator Events ─────────────────────────────────
45
69
 
46
70
  export type OrchestratorEvent =
@@ -70,6 +94,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
70
94
  private settingsValidCache = new Map<string, number>(); // filePath → mtime (validated ok)
71
95
  private agentRunningMsg = new Map<string, string>(); // agentId → messageId currently being processed
72
96
  private reconcileTick = 0; // counts health check ticks for 60s reconcile
97
+ private _topoCache: WorkspaceTopo | null = null; // cached workspace topology
98
+ private roleInjectState = new Map<string, { lastInjectAt: number; msgsSinceInject: number }>(); // per-agent role reminder tracking
73
99
 
74
100
  /** Emit a log event (auto-persisted via constructor listener) */
75
101
  emitLog(agentId: string, entry: any): void {
@@ -156,16 +182,29 @@ export class WorkspaceOrchestrator extends EventEmitter {
156
182
  return `Work directory conflict: "${config.label}" (${newDir}/) overlaps with "${entry.config.label}" (${existingDir}/). Nested directories not allowed.`;
157
183
  }
158
184
 
159
- // Check output path overlap
185
+ // Output path overlap is allowed — multiple agents can write to the same
186
+ // output directory (e.g., Engineer and UI Designer both output to src/).
187
+ // Work directory uniqueness already prevents file-level conflicts.
188
+ }
189
+ return null;
190
+ }
191
+
192
+ /** Check for non-blocking output overlaps — returns a warning message if any */
193
+ private getOutputWarnings(config: WorkspaceAgentConfig, excludeId?: string): string | null {
194
+ if (config.type === 'input' || !config.outputs?.length) return null;
195
+ const normalize = (p: string) => p.replace(/^\.?\//, '').replace(/\/$/, '') || '.';
196
+ const overlaps: string[] = [];
197
+ for (const [id, entry] of this.agents) {
198
+ if (id === excludeId || entry.config.type === 'input') continue;
160
199
  for (const out of config.outputs) {
161
- for (const existing of entry.config.outputs) {
200
+ for (const existing of entry.config.outputs || []) {
162
201
  if (normalize(out) === normalize(existing)) {
163
- return `Output conflict: "${config.label}" and "${entry.config.label}" both output to "${out}"`;
202
+ overlaps.push(`${entry.config.label} also outputs to "${out}"`);
164
203
  }
165
204
  }
166
205
  }
167
206
  }
168
- return null;
207
+ return overlaps.length ? `Output overlap (non-blocking): ${overlaps.join('; ')}` : null;
169
208
  }
170
209
 
171
210
  /** Detect if adding dependsOn edges would create a cycle in the DAG */
@@ -229,6 +268,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
229
268
  addAgent(config: WorkspaceAgentConfig): void {
230
269
  const conflict = this.validateOutputs(config);
231
270
  if (conflict) throw new Error(conflict);
271
+ const warning = this.getOutputWarnings(config);
272
+ if (warning) console.warn(`[workspace] ${warning}`);
232
273
 
233
274
  // Check DAG cycle before adding
234
275
  const cycleErr = this.detectCycle(config.id, config.dependsOn);
@@ -308,8 +349,22 @@ export class WorkspaceOrchestrator extends EventEmitter {
308
349
  updateAgentConfig(id: string, config: WorkspaceAgentConfig): void {
309
350
  const entry = this.agents.get(id);
310
351
  if (!entry) return;
352
+ // Validate agentId exists — fallback to default if deleted from Settings
353
+ if (config.agentId) {
354
+ try {
355
+ const { listAgents, getDefaultAgentId } = require('../agents/index');
356
+ const validAgents = new Set((listAgents() as any[]).map((a: any) => a.id));
357
+ if (!validAgents.has(config.agentId)) {
358
+ const fallback = getDefaultAgentId() || 'claude';
359
+ console.log(`[workspace] ${config.label}: agent "${config.agentId}" not found, falling back to "${fallback}"`);
360
+ config.agentId = fallback;
361
+ }
362
+ } catch {}
363
+ }
311
364
  const conflict = this.validateOutputs(config, id);
312
365
  if (conflict) throw new Error(conflict);
366
+ const warning = this.getOutputWarnings(config, id);
367
+ if (warning) console.warn(`[workspace] ${warning}`);
313
368
  const cycleErr = this.detectCycle(id, config.dependsOn);
314
369
  if (cycleErr) throw new Error(cycleErr);
315
370
  this.validatePrimaryRules(config, id);
@@ -744,8 +799,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
744
799
  }
745
800
  }
746
801
 
747
- // Sync role CLAUDE.md so CLI agents (Claude Code) see it as system instructions
748
- this.syncRoleToClaudeMd(config);
802
+ // Role is now injected via buildUpstreamContext (headless) and persistent session preamble (terminal).
803
+ // No longer writes to CLAUDE.md — project files stay clean when daemon stops.
749
804
 
750
805
  let upstreamContext = this.buildUpstreamContext(config);
751
806
  if (userInput) {
@@ -879,6 +934,23 @@ export class WorkspaceOrchestrator extends EventEmitter {
879
934
  installForgeSkills(this.projectPath, this.workspaceId, '', Number(process.env.PORT) || 8403);
880
935
  } catch {}
881
936
 
937
+ // Validate agent IDs — fallback to default if configured agent was deleted from Settings
938
+ let defaultAgentId = 'claude';
939
+ try {
940
+ const { listAgents, getDefaultAgentId } = await import('../agents/index') as any;
941
+ const validAgents = new Set((listAgents() as any[]).map(a => a.id));
942
+ defaultAgentId = getDefaultAgentId() || 'claude';
943
+ for (const [id, entry] of this.agents) {
944
+ if (entry.config.type === 'input') continue;
945
+ if (entry.config.agentId && !validAgents.has(entry.config.agentId)) {
946
+ console.log(`[daemon] ${entry.config.label}: agent "${entry.config.agentId}" not found, falling back to "${defaultAgentId}"`);
947
+ entry.config.agentId = defaultAgentId;
948
+ this.emit('event', { type: 'log', agentId: id, entry: { type: 'system', subtype: 'warning', content: `Agent "${entry.config.agentId}" not found in Settings — using default "${defaultAgentId}"`, timestamp: new Date().toISOString() } } as any);
949
+ this.saveNow();
950
+ }
951
+ }
952
+ } catch {}
953
+
882
954
  // Start each smith one by one, verify each starts correctly
883
955
  let started = 0;
884
956
  let failed = 0;
@@ -903,8 +975,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
903
975
  throw new Error('Worker not created');
904
976
  }
905
977
 
906
- // 3. Set smith status to active
907
- entry.state.smithStatus = 'active';
978
+ // 3. Set smith status persistent session agents stay 'starting' until ensurePersistentSession completes
979
+ entry.state.smithStatus = entry.config.persistentSession ? 'starting' : 'active';
908
980
  entry.state.error = undefined;
909
981
 
910
982
  // 4. Start message loop (delayed for persistent session agents — session must exist first)
@@ -916,10 +988,10 @@ export class WorkspaceOrchestrator extends EventEmitter {
916
988
  this.updateAgentLiveness(id);
917
989
 
918
990
  // 6. Notify frontend
919
- this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } satisfies WorkerEvent);
991
+ this.emit('event', { type: 'smith_status', agentId: id, smithStatus: entry.state.smithStatus } as any);
920
992
 
921
993
  started++;
922
- console.log(`[daemon] ✓ ${entry.config.label}: active (task=${entry.state.taskStatus})`);
994
+ console.log(`[daemon] ✓ ${entry.config.label}: ${entry.state.smithStatus} (task=${entry.state.taskStatus})`);
923
995
  } catch (err: any) {
924
996
  entry.state.smithStatus = 'down';
925
997
  entry.state.error = `Failed to start: ${err.message}`;
@@ -929,18 +1001,36 @@ export class WorkspaceOrchestrator extends EventEmitter {
929
1001
  }
930
1002
  }
931
1003
 
1004
+ // Migration: clean any legacy Forge blocks from CLAUDE.md files (role/topo no longer written to disk)
1005
+ this.cleanLegacyClaudeMdBlocks();
1006
+
932
1007
  // Create persistent terminal sessions, then start their message loops
933
1008
  for (const [id, entry] of this.agents) {
934
1009
  if (entry.config.type === 'input' || !entry.config.persistentSession) continue;
935
1010
  await this.ensurePersistentSession(id, entry.config);
936
- // Only start message loop if session was created successfully
1011
+ // Set active now that session + boundSessionId are ready
1012
+ if (entry.state.smithStatus === 'starting') {
1013
+ entry.state.smithStatus = 'active';
1014
+ this.emit('event', { type: 'smith_status', agentId: id, smithStatus: 'active' } as any);
1015
+ }
937
1016
  if (entry.state.smithStatus === 'active') {
1017
+ // Inject role preamble so Claude knows its role (replaces CLAUDE.md writing)
1018
+ if (entry.config.role?.trim()) {
1019
+ const preamble = this.buildRolePreamble(entry.config);
1020
+ if (this.injectIntoSession(id, preamble)) {
1021
+ this.markRoleInjected(id);
1022
+ console.log(`[daemon] ${entry.config.label}: injected role preamble`);
1023
+ }
1024
+ }
938
1025
  this.startMessageLoop(id);
939
1026
  } else {
940
1027
  console.log(`[daemon] ${entry.config.label}: skipped message loop (smith=${entry.state.smithStatus})`);
941
1028
  }
942
1029
  }
943
1030
 
1031
+ // Build workspace topology cache (all smiths can query via MCP get_agents)
1032
+ this.rebuildTopo();
1033
+
944
1034
  // Start watch loops for agents with watch config
945
1035
  this.watchManager.start();
946
1036
 
@@ -1085,6 +1175,20 @@ export class WorkspaceOrchestrator extends EventEmitter {
1085
1175
  entry.worker = null;
1086
1176
  }
1087
1177
 
1178
+ // 2b. For persistent sessions: send /clear to reset Claude's context if user is attached.
1179
+ // This is a Claude Code slash command — no LLM call, just a local context reset.
1180
+ if (entry.state.tmuxSession && entry.config.role?.trim()) {
1181
+ let isAttached = false;
1182
+ try {
1183
+ const info = execSync(`tmux display-message -t "${entry.state.tmuxSession}" -p "#{session_attached}" 2>/dev/null`, { timeout: 3000, encoding: 'utf-8' }).trim();
1184
+ isAttached = info !== '0';
1185
+ } catch {}
1186
+ if (isAttached) {
1187
+ try { this.injectIntoSession(id, '/clear'); } catch {}
1188
+ }
1189
+ }
1190
+ this.roleInjectState.delete(id);
1191
+
1088
1192
  // 3. Kill tmux session (skip if user is attached to it)
1089
1193
  if (entry.state.tmuxSession) {
1090
1194
  let isAttached = false;
@@ -1856,69 +1960,82 @@ export class WorkspaceOrchestrator extends EventEmitter {
1856
1960
 
1857
1961
  // ─── Private ───────────────────────────────────────────
1858
1962
 
1859
- /** Sync agent role description into CLAUDE.md so CLI agents read it natively */
1860
- private syncRoleToClaudeMd(config: WorkspaceAgentConfig): void {
1861
- if (!config.role?.trim()) return;
1862
- const workDir = join(this.projectPath, config.workDir || '');
1863
- const claudeMdPath = join(workDir, 'CLAUDE.md');
1963
+ /** Rebuild workspace topology cache called on agent changes and daemon start */
1964
+ private rebuildTopo(): void {
1965
+ // Topological sort
1966
+ const sorted: WorkspaceAgentConfig[] = [];
1967
+ const visited = new Set<string>();
1968
+ const entries = Array.from(this.agents.values()).filter(e => e.config.type !== 'input');
1969
+ const configMap = new Map(entries.map(e => [e.config.id, e.config]));
1970
+
1971
+ const visit = (id: string) => {
1972
+ if (visited.has(id)) return;
1973
+ visited.add(id);
1974
+ const c = configMap.get(id);
1975
+ if (!c) return;
1976
+ for (const dep of c.dependsOn) visit(dep);
1977
+ sorted.push(c);
1978
+ };
1979
+ for (const e of entries) visit(e.config.id);
1980
+
1981
+ const agents: TopoAgent[] = sorted.map(c => {
1982
+ const state = this.agents.get(c.id)?.state;
1983
+ return {
1984
+ id: c.id,
1985
+ label: c.label,
1986
+ icon: c.icon,
1987
+ role: c.role || '',
1988
+ roleSummary: (c.role || '').split('\n')[0].slice(0, 150),
1989
+ primary: !!c.primary,
1990
+ dependsOn: c.dependsOn.map(d => this.agents.get(d)?.config.label || d),
1991
+ dependsOnIds: [...c.dependsOn],
1992
+ workDir: c.workDir || './',
1993
+ outputs: c.outputs || [],
1994
+ steps: (c.steps || []).map(s => s.label),
1995
+ smithStatus: state?.smithStatus || 'down',
1996
+ taskStatus: state?.taskStatus || 'idle',
1997
+ };
1998
+ });
1864
1999
 
1865
- // Build plugin docs from agent's plugins
1866
- let pluginDocs = '';
1867
- if (config.plugins?.length) {
1868
- try {
1869
- const { getInstalledPlugin, listInstalledPlugins } = require('../plugins/registry');
1870
- const installed = listInstalledPlugins();
1871
- const agentPlugins = config.plugins;
1872
- // Find all instances that match agent's plugin list (by source or direct id)
1873
- const relevant = installed.filter((p: any) =>
1874
- agentPlugins.includes(p.id) || agentPlugins.includes(p.source || p.id)
1875
- );
1876
- if (relevant.length > 0) {
1877
- pluginDocs = '\n\n## Available Plugins (via MCP run_plugin)\n';
1878
- for (const p of relevant) {
1879
- const def = p.definition;
1880
- const name = p.instanceName || def.name;
1881
- pluginDocs += `\n### ${def.icon} ${name} (id: "${p.id}")\n`;
1882
- if (def.description) pluginDocs += `${def.description}\n`;
1883
- pluginDocs += '\nActions:\n';
1884
- for (const [actionName, action] of Object.entries(def.actions) as any[]) {
1885
- pluginDocs += `- **${actionName}** (${action.run})`;
1886
- if (def.defaultAction === actionName) pluginDocs += ' [default]';
1887
- pluginDocs += '\n';
1888
- }
1889
- if (Object.keys(def.params).length > 0) {
1890
- pluginDocs += 'Params: ' + Object.entries(def.params).map(([k, v]: any) =>
1891
- `${k}${v.required ? '*' : ''} (${v.type})`
1892
- ).join(', ') + '\n';
1893
- }
1894
- pluginDocs += `\nUsage: run_plugin({ plugin: "${p.id}", action: "<action>", params: { ... } })\n`;
1895
- }
1896
- }
1897
- } catch {}
1898
- }
2000
+ this._topoCache = {
2001
+ agents,
2002
+ flow: sorted.map(c => c.label).join(' → '),
2003
+ updatedAt: Date.now(),
2004
+ };
2005
+ }
2006
+
2007
+ /** Get cached workspace topology (always fresh rebuilt on every agent change) */
2008
+ getWorkspaceTopo(): WorkspaceTopo {
2009
+ if (!this._topoCache) this.rebuildTopo();
2010
+ return this._topoCache!;
2011
+ }
1899
2012
 
1900
- const MARKER_START = '<!-- forge:agent-role -->';
1901
- const MARKER_END = '<!-- /forge:agent-role -->';
1902
- const roleBlock = `${MARKER_START}\n## Agent Role (managed by Forge)\n\n${config.role.trim()}${pluginDocs}\n${MARKER_END}`;
2013
+ /** Remove stale Forge blocks from all agents' CLAUDE.md files (migration cleanup).
2014
+ * Role is no longer written to CLAUDE.md — this just removes legacy artifacts. */
2015
+ private cleanLegacyClaudeMdBlocks(): void {
2016
+ const roleRegex = /<!-- forge:agent-role -->[\s\S]*?<!-- \/forge:agent-role -->\n?/;
2017
+ const topoRegex = /<!-- forge:workspace-topo -->[\s\S]*?<!-- \/forge:workspace-topo -->\n?/;
1903
2018
 
1904
- try {
1905
- let content = '';
1906
- if (existsSync(claudeMdPath)) {
1907
- content = readFileSync(claudeMdPath, 'utf-8');
1908
- // Replace existing forge block
1909
- const regex = new RegExp(`${MARKER_START}[\\s\\S]*?${MARKER_END}`);
1910
- if (regex.test(content)) {
1911
- const updated = content.replace(regex, roleBlock);
1912
- if (updated !== content) writeFileSync(claudeMdPath, updated);
1913
- return;
2019
+ for (const [, entry] of this.agents) {
2020
+ if (entry.config.type === 'input') continue;
2021
+ const workDir = join(this.projectPath, entry.config.workDir || '');
2022
+ const claudeMdPath = join(workDir, 'CLAUDE.md');
2023
+ if (!existsSync(claudeMdPath)) continue;
2024
+
2025
+ try {
2026
+ let content = readFileSync(claudeMdPath, 'utf-8');
2027
+ const before = content;
2028
+ if (roleRegex.test(content)) content = content.replace(roleRegex, '');
2029
+ if (topoRegex.test(content)) content = content.replace(topoRegex, '');
2030
+ if (content !== before) {
2031
+ content = content.trimEnd() + (content.trim() ? '\n' : '');
2032
+ if (content.trim()) writeFileSync(claudeMdPath, content);
2033
+ else unlinkSync(claudeMdPath); // was only forge content, delete empty file
2034
+ console.log(`[workspace] Cleaned legacy Forge blocks from ${claudeMdPath}`);
1914
2035
  }
2036
+ } catch (err: any) {
2037
+ console.warn(`[workspace] Failed to clean CLAUDE.md at ${claudeMdPath}: ${err.message}`);
1915
2038
  }
1916
- // Append
1917
- mkdirSync(workDir, { recursive: true });
1918
- const separator = content && !content.endsWith('\n') ? '\n\n' : content ? '\n' : '';
1919
- writeFileSync(claudeMdPath, content + separator + roleBlock + '\n');
1920
- } catch (err) {
1921
- console.warn(`[workspace] Failed to sync role to CLAUDE.md for ${config.label}:`, err);
1922
2039
  }
1923
2040
  }
1924
2041
 
@@ -1945,11 +2062,89 @@ export class WorkspaceOrchestrator extends EventEmitter {
1945
2062
  }
1946
2063
  }
1947
2064
 
2065
+ /** Build a compact topo summary injected into every agent task */
2066
+ private buildTopoSummary(selfId: string): string {
2067
+ const topo = this.getWorkspaceTopo();
2068
+ if (topo.agents.length === 0) return '';
2069
+
2070
+ const lines: string[] = ['## Workspace Team'];
2071
+ lines.push(`Flow: ${topo.flow}`);
2072
+
2073
+ // Missing roles hint
2074
+ const labels = new Set(topo.agents.map(a => a.label.toLowerCase()));
2075
+ const standard = ['architect', 'engineer', 'qa', 'reviewer', 'pm', 'lead'];
2076
+ const missing = standard.filter(r => !labels.has(r));
2077
+ if (missing.length > 0) lines.push(`Missing: ${missing.join(', ')}`);
2078
+
2079
+ for (const a of topo.agents) {
2080
+ const me = a.id === selfId ? ' ← you' : '';
2081
+ const status = `${a.smithStatus}/${a.taskStatus}`;
2082
+ lines.push(`- ${a.icon} ${a.label}${me} [${status}]: ${a.roleSummary}`);
2083
+ }
2084
+ return lines.join('\n');
2085
+ }
2086
+
2087
+ /** Build full role preamble — injected once when persistent session starts */
2088
+ private buildRolePreamble(config: WorkspaceAgentConfig): string {
2089
+ const roleText = (config.role || '').trim() || '(no role defined)';
2090
+ return `=== SMITH CONTEXT (managed by Forge) ===
2091
+ You are ${config.label}. Work dir: ${config.workDir || './'}.
2092
+
2093
+ Your role and responsibilities:
2094
+ ${roleText}
2095
+
2096
+ ---
2097
+ Silently ingest this context. Do NOT respond — await an actual task.`;
2098
+ }
2099
+
2100
+ /** Build short role reminder — injected periodically to combat auto-compaction */
2101
+ private buildRoleReminder(config: WorkspaceAgentConfig): string {
2102
+ const roleText = (config.role || '').trim();
2103
+ // First 300 chars of role, plus label
2104
+ const summary = roleText.length > 300 ? roleText.slice(0, 300) + '...' : roleText;
2105
+ return `[Role reminder — you are ${config.label}]\n${summary}\n---`;
2106
+ }
2107
+
2108
+ /** Check if a role reminder should be re-injected before next bus message */
2109
+ private needsRoleReminder(agentId: string): boolean {
2110
+ const st = this.roleInjectState.get(agentId);
2111
+ if (!st) return false; // no preamble sent yet — session is starting
2112
+ const now = Date.now();
2113
+ const REMIND_EVERY_MSGS = 10;
2114
+ const REMIND_EVERY_MS = 30 * 60 * 1000; // 30 min
2115
+ if (st.msgsSinceInject >= REMIND_EVERY_MSGS) return true;
2116
+ if (now - st.lastInjectAt >= REMIND_EVERY_MS) return true;
2117
+ return false;
2118
+ }
2119
+
2120
+ /** Mark that role was just injected for this agent */
2121
+ private markRoleInjected(agentId: string): void {
2122
+ this.roleInjectState.set(agentId, { lastInjectAt: Date.now(), msgsSinceInject: 0 });
2123
+ }
2124
+
2125
+ /** Increment message counter since last role injection */
2126
+ private incrementMsgCount(agentId: string): void {
2127
+ const st = this.roleInjectState.get(agentId);
2128
+ if (st) st.msgsSinceInject++;
2129
+ }
2130
+
1948
2131
  /** Build context string from upstream agents' outputs */
1949
2132
  private buildUpstreamContext(config: WorkspaceAgentConfig): string | undefined {
1950
- if (config.dependsOn.length === 0) return undefined;
2133
+ // Always prepend: role + workspace team summary
2134
+ const roleBlock = config.role?.trim() ? `## Your Role (${config.label})\n${config.role.trim()}` : '';
2135
+ const topoSummary = this.buildTopoSummary(config.id);
2136
+
2137
+ const headerSections: string[] = [];
2138
+ if (roleBlock) headerSections.push(roleBlock);
2139
+ if (topoSummary) headerSections.push(topoSummary);
2140
+ const header = headerSections.join('\n\n');
2141
+
2142
+ if (config.dependsOn.length === 0) {
2143
+ return header || undefined;
2144
+ }
1951
2145
 
1952
2146
  const sections: string[] = [];
2147
+ if (header) sections.push(header);
1953
2148
 
1954
2149
  for (const depId of config.dependsOn) {
1955
2150
  const dep = this.agents.get(depId);
@@ -2117,7 +2312,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
2117
2312
  private getCliSessionDir(workDir?: string): string {
2118
2313
  const projectPath = workDir && workDir !== './' && workDir !== '.'
2119
2314
  ? join(this.projectPath, workDir) : this.projectPath;
2120
- const encoded = resolve(projectPath).replace(/\//g, '-');
2315
+ // Claude Code encodes paths by replacing all non-alphanumeric chars with '-'
2316
+ const encoded = resolve(projectPath).replace(/[^a-zA-Z0-9]/g, '-');
2121
2317
  return join(homedir(), '.claude', 'projects', encoded);
2122
2318
  }
2123
2319
 
@@ -2582,8 +2778,7 @@ export class WorkspaceOrchestrator extends EventEmitter {
2582
2778
  const tmpFile = `/tmp/forge-inject-${Date.now()}.txt`;
2583
2779
  writeFileSync(tmpFile, text);
2584
2780
  execSync(`tmux load-buffer ${tmpFile}`, { timeout: 5000 });
2585
- execSync(`tmux paste-buffer -t "${tmuxSession}"`, { timeout: 5000 });
2586
- execSync(`tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
2781
+ execSync(`tmux paste-buffer -t "${tmuxSession}" && sleep 0.2 && tmux send-keys -t "${tmuxSession}" Enter`, { timeout: 5000 });
2587
2782
  try { unlinkSync(tmpFile); } catch {}
2588
2783
  return true;
2589
2784
  } catch (err: any) {
@@ -2842,7 +3037,16 @@ export class WorkspaceOrchestrator extends EventEmitter {
2842
3037
 
2843
3038
  // Terminal mode → inject; headless → worker (claude -p)
2844
3039
  if (isTerminalMode) {
2845
- const injected = this.injectIntoSession(agentId, nextMsg.payload.content || nextMsg.payload.action);
3040
+ // Check if role reminder needed (combats auto-compaction over long sessions)
3041
+ let messageText = nextMsg.payload.content || nextMsg.payload.action;
3042
+ if (this.needsRoleReminder(agentId) && entry.config.role?.trim()) {
3043
+ messageText = this.buildRoleReminder(entry.config) + '\n\n' + messageText;
3044
+ this.markRoleInjected(agentId);
3045
+ console.log(`[inbox] ${entry.config.label}: prepended role reminder`);
3046
+ } else {
3047
+ this.incrementMsgCount(agentId);
3048
+ }
3049
+ const injected = this.injectIntoSession(agentId, messageText);
2846
3050
  if (injected) {
2847
3051
  entry.state.taskStatus = 'running';
2848
3052
  this.emit('event', { type: 'task_status', agentId, taskStatus: 'running' } as any);
@@ -3025,8 +3229,18 @@ export class WorkspaceOrchestrator extends EventEmitter {
3025
3229
  r.type === 'response' && r.payload.replyTo === m.id
3026
3230
  )
3027
3231
  );
3028
- if (!hasPendingRequests) {
3029
- console.log('[workspace] All agents complete, no pending requests. Workspace done.');
3232
+ // Also check request documents are all done
3233
+ let requestsComplete = true;
3234
+ try {
3235
+ const { listRequests } = require('./requests');
3236
+ const allReqs = listRequests(this.projectPath);
3237
+ if (allReqs.length > 0) {
3238
+ requestsComplete = allReqs.every((r: any) => r.status === 'done' || r.status === 'rejected');
3239
+ }
3240
+ } catch {}
3241
+
3242
+ if (!hasPendingRequests && requestsComplete) {
3243
+ console.log('[workspace] All agents complete, no pending requests, all request docs done. Workspace done.');
3030
3244
  this.emit('event', { type: 'workspace_complete' } satisfies OrchestratorEvent);
3031
3245
  }
3032
3246
  }
@@ -3129,6 +3343,8 @@ export class WorkspaceOrchestrator extends EventEmitter {
3129
3343
 
3130
3344
  /** Emit agents_changed so SSE pushes the updated list to frontend */
3131
3345
  private emitAgentsChanged(): void {
3346
+ // Refresh topology cache so MCP queries always return current state
3347
+ this.rebuildTopo();
3132
3348
  const agents = Array.from(this.agents.values()).map(e => e.config);
3133
3349
  const agentStates = this.getAllAgentStates();
3134
3350
  this.emit('event', { type: 'agents_changed', agents, agentStates } satisfies WorkerEvent);