chapterhouse 0.1.1
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/LICENSE +23 -0
- package/README.md +363 -0
- package/agents/chapterhouse.agent.md +40 -0
- package/agents/coder.agent.md +38 -0
- package/agents/designer.agent.md +43 -0
- package/agents/general-purpose.agent.md +30 -0
- package/dist/api/auth.js +159 -0
- package/dist/api/auth.test.js +463 -0
- package/dist/api/errors.js +95 -0
- package/dist/api/errors.test.js +89 -0
- package/dist/api/rate-limit.js +85 -0
- package/dist/api/server-runtime.js +62 -0
- package/dist/api/server.js +651 -0
- package/dist/api/server.test.js +385 -0
- package/dist/api/sse.integration.test.js +270 -0
- package/dist/api/sse.js +7 -0
- package/dist/api/team.js +196 -0
- package/dist/api/team.test.js +466 -0
- package/dist/cli.js +102 -0
- package/dist/config.js +299 -0
- package/dist/config.phase3.test.js +20 -0
- package/dist/config.test.js +148 -0
- package/dist/copilot/agents.js +447 -0
- package/dist/copilot/agents.squad.test.js +72 -0
- package/dist/copilot/classifier.js +72 -0
- package/dist/copilot/client.js +32 -0
- package/dist/copilot/client.test.js +100 -0
- package/dist/copilot/episode-writer.js +219 -0
- package/dist/copilot/episode-writer.test.js +41 -0
- package/dist/copilot/mcp-config.js +22 -0
- package/dist/copilot/okr-mapper.js +196 -0
- package/dist/copilot/okr-mapper.test.js +114 -0
- package/dist/copilot/orchestrator.js +685 -0
- package/dist/copilot/orchestrator.test.js +523 -0
- package/dist/copilot/router.js +142 -0
- package/dist/copilot/router.test.js +119 -0
- package/dist/copilot/skills.js +125 -0
- package/dist/copilot/standup.js +138 -0
- package/dist/copilot/standup.test.js +132 -0
- package/dist/copilot/system-message.js +143 -0
- package/dist/copilot/system-message.test.js +17 -0
- package/dist/copilot/tools.js +1212 -0
- package/dist/copilot/tools.okr.test.js +260 -0
- package/dist/copilot/tools.squad.test.js +168 -0
- package/dist/daemon.js +235 -0
- package/dist/home-path.js +12 -0
- package/dist/home-path.test.js +11 -0
- package/dist/integrations/ado-analytics.js +178 -0
- package/dist/integrations/ado-analytics.test.js +284 -0
- package/dist/integrations/ado-client.js +227 -0
- package/dist/integrations/ado-client.test.js +176 -0
- package/dist/integrations/ado-schema.js +25 -0
- package/dist/integrations/ado-schema.test.js +39 -0
- package/dist/integrations/ado-skill.js +55 -0
- package/dist/integrations/report-generator.js +114 -0
- package/dist/integrations/report-generator.test.js +62 -0
- package/dist/integrations/team-push.js +144 -0
- package/dist/integrations/team-push.test.js +178 -0
- package/dist/integrations/teams-notify.js +108 -0
- package/dist/integrations/teams-notify.test.js +135 -0
- package/dist/paths.js +41 -0
- package/dist/setup.js +149 -0
- package/dist/shutdown-signals.js +13 -0
- package/dist/shutdown-signals.test.js +33 -0
- package/dist/squad/charter.js +108 -0
- package/dist/squad/charter.test.js +89 -0
- package/dist/squad/context.js +48 -0
- package/dist/squad/context.test.js +59 -0
- package/dist/squad/discovery.js +280 -0
- package/dist/squad/discovery.test.js +93 -0
- package/dist/squad/index.js +7 -0
- package/dist/squad/mirror.js +81 -0
- package/dist/squad/mirror.scheduler.js +78 -0
- package/dist/squad/mirror.scheduler.test.js +197 -0
- package/dist/squad/mirror.test.js +172 -0
- package/dist/squad/registry.js +162 -0
- package/dist/squad/registry.test.js +31 -0
- package/dist/squad/squad-coordinator-system-message.test.js +190 -0
- package/dist/squad/squad-session-routing.test.js +260 -0
- package/dist/squad/types.js +4 -0
- package/dist/status.js +25 -0
- package/dist/status.test.js +22 -0
- package/dist/store/db.js +290 -0
- package/dist/store/db.test.js +126 -0
- package/dist/store/squad-sessions.test.js +341 -0
- package/dist/test/setup-env.js +3 -0
- package/dist/update.js +112 -0
- package/dist/update.test.js +25 -0
- package/dist/wiki/context.js +138 -0
- package/dist/wiki/fs.js +195 -0
- package/dist/wiki/fs.test.js +39 -0
- package/dist/wiki/index-manager.js +359 -0
- package/dist/wiki/index-manager.test.js +129 -0
- package/dist/wiki/lock.js +26 -0
- package/dist/wiki/lock.test.js +30 -0
- package/dist/wiki/log-manager.js +20 -0
- package/dist/wiki/migrate.js +306 -0
- package/dist/wiki/okr.test.js +101 -0
- package/dist/wiki/path-utils.js +4 -0
- package/dist/wiki/path-utils.test.js +8 -0
- package/dist/wiki/seed-team-wiki.js +296 -0
- package/dist/wiki/seed-team-wiki.test.js +69 -0
- package/dist/wiki/team-sync.js +212 -0
- package/dist/wiki/team-sync.test.js +185 -0
- package/dist/wiki/templates/okr.js +98 -0
- package/package.json +72 -0
- package/skills/.gitkeep +0 -0
- package/skills/find-skills/SKILL.md +161 -0
- package/skills/find-skills/_meta.json +4 -0
- package/skills/frontend-design/LICENSE.txt +177 -0
- package/skills/frontend-design/SKILL.md +42 -0
- package/skills/squad/SKILL.md +76 -0
- package/web/dist/assets/index-D-e7K-fT.css +10 -0
- package/web/dist/assets/index-DAg9IrpO.js +142 -0
- package/web/dist/assets/index-DAg9IrpO.js.map +1 -0
- package/web/dist/chapterhouse-icon.png +0 -0
- package/web/dist/chapterhouse-icon.svg +42 -0
- package/web/dist/chapterhouse-logo.svg +46 -0
- package/web/dist/index.html +15 -0
|
@@ -0,0 +1,685 @@
|
|
|
1
|
+
import { approveAll } from "@github/copilot-sdk";
|
|
2
|
+
import { createTools } from "./tools.js";
|
|
3
|
+
import { getOrchestratorSystemMessage } from "./system-message.js";
|
|
4
|
+
import { config, DEFAULT_MODEL } from "../config.js";
|
|
5
|
+
import { loadMcpConfig } from "./mcp-config.js";
|
|
6
|
+
import { getSkillDirectories } from "./skills.js";
|
|
7
|
+
import { resetClient } from "./client.js";
|
|
8
|
+
import { logConversation, getState, setState, deleteState, getCopilotSession, upsertCopilotSession, getTaskSessionKey } from "../store/db.js";
|
|
9
|
+
import { maybeWriteEpisode } from "./episode-writer.js";
|
|
10
|
+
import { getWikiSummary } from "../wiki/context.js";
|
|
11
|
+
import { SESSIONS_DIR } from "../paths.js";
|
|
12
|
+
import { resolveModel } from "./router.js";
|
|
13
|
+
import { loadAgents, ensureDefaultAgents, clearActiveTasks, getAgentRegistry, setActiveAgent, parseAtMention, buildAgentRoster, getActiveTasks, } from "./agents.js";
|
|
14
|
+
import { normalizeProjectPath, setChannelProject } from "../squad/context.js";
|
|
15
|
+
import { getSquadCoordinatorSystemMessage } from "../squad/charter.js";
|
|
16
|
+
/**
|
|
17
|
+
* Permission handler for the orchestrator session.
|
|
18
|
+
* Approves all tool requests so @chapterhouse has full access to all tools.
|
|
19
|
+
*/
|
|
20
|
+
const orchestratorPermissionHandler = approveAll;
|
|
21
|
+
const MAX_RETRIES = 3;
|
|
22
|
+
const RECONNECT_DELAYS_MS = [1_000, 3_000, 10_000];
|
|
23
|
+
const HEALTH_CHECK_INTERVAL_MS = 30_000;
|
|
24
|
+
const ORCHESTRATOR_SESSION_KEY = "orchestrator_session_id";
|
|
25
|
+
const LAST_AUTHENTICATED_USER_KEY = "last_authenticated_user";
|
|
26
|
+
let logMessage = () => { };
|
|
27
|
+
export function setMessageLogger(fn) {
|
|
28
|
+
logMessage = fn;
|
|
29
|
+
}
|
|
30
|
+
let proactiveNotifyFn;
|
|
31
|
+
export function setProactiveNotify(fn) {
|
|
32
|
+
proactiveNotifyFn = fn;
|
|
33
|
+
}
|
|
34
|
+
let copilotClient;
|
|
35
|
+
let healthCheckTimer;
|
|
36
|
+
let currentUserContext;
|
|
37
|
+
let currentAuthenticatedUser;
|
|
38
|
+
let currentAuthorizationHeader;
|
|
39
|
+
// Router state
|
|
40
|
+
let recentTiers = [];
|
|
41
|
+
let lastRouteResult;
|
|
42
|
+
export function getLastRouteResult() {
|
|
43
|
+
return lastRouteResult;
|
|
44
|
+
}
|
|
45
|
+
// Session map — one entry per session key ("default" or "project:{normalizedRoot}")
|
|
46
|
+
const sessionMap = new Map();
|
|
47
|
+
const sessionModelMap = new Map();
|
|
48
|
+
const sessionCreatePromises = new Map();
|
|
49
|
+
// Track which session key the currently-executing turn belongs to (for abort + task routing)
|
|
50
|
+
let currentProcessingSessionKey;
|
|
51
|
+
export function getCurrentSessionKey() {
|
|
52
|
+
return currentProcessingSessionKey ?? "default";
|
|
53
|
+
}
|
|
54
|
+
const messageQueue = [];
|
|
55
|
+
let processing = false;
|
|
56
|
+
let currentCallback;
|
|
57
|
+
/** The channel currently being processed — tools use this to tag new workers. */
|
|
58
|
+
let currentSourceChannel;
|
|
59
|
+
/** Get the channel that originated the message currently being processed. */
|
|
60
|
+
export function getCurrentSourceChannel() {
|
|
61
|
+
return currentSourceChannel;
|
|
62
|
+
}
|
|
63
|
+
let currentChannelKey;
|
|
64
|
+
export function getCurrentChannelKey() {
|
|
65
|
+
return currentChannelKey;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The activity callback for the message currently being processed. Tool handlers
|
|
69
|
+
* (notably `delegate_to_agent`) read this to forward child-session events back
|
|
70
|
+
* to the parent's SSE connection.
|
|
71
|
+
*/
|
|
72
|
+
let currentActivityCallback;
|
|
73
|
+
export function getCurrentActivityCallback() {
|
|
74
|
+
return currentActivityCallback;
|
|
75
|
+
}
|
|
76
|
+
export function getCurrentAuthenticatedUser() {
|
|
77
|
+
return currentAuthenticatedUser;
|
|
78
|
+
}
|
|
79
|
+
export function getLastAuthenticatedUser() {
|
|
80
|
+
const raw = getState(LAST_AUTHENTICATED_USER_KEY);
|
|
81
|
+
if (!raw) {
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
try {
|
|
85
|
+
const parsed = JSON.parse(raw);
|
|
86
|
+
if (!parsed?.id || !parsed?.name || !parsed?.email || !parsed?.role) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
return parsed;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
export function getCurrentAuthorizationHeader() {
|
|
96
|
+
return currentAuthorizationHeader;
|
|
97
|
+
}
|
|
98
|
+
function getSessionConfig() {
|
|
99
|
+
const tools = createTools({
|
|
100
|
+
client: copilotClient,
|
|
101
|
+
onAgentTaskComplete: feedAgentResult,
|
|
102
|
+
});
|
|
103
|
+
const mcpServers = loadMcpConfig();
|
|
104
|
+
const skillDirectories = getSkillDirectories();
|
|
105
|
+
return { tools, mcpServers, skillDirectories };
|
|
106
|
+
}
|
|
107
|
+
function getSystemMessageOptions(memorySummary, projectRoot) {
|
|
108
|
+
return {
|
|
109
|
+
selfEditEnabled: config.selfEditEnabled,
|
|
110
|
+
memorySummary: memorySummary || undefined,
|
|
111
|
+
agentRoster: buildAgentRoster(projectRoot),
|
|
112
|
+
userContext: currentUserContext,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
function sameUserContext(a, b) {
|
|
116
|
+
return a?.name === b?.name && a?.role === b?.role;
|
|
117
|
+
}
|
|
118
|
+
function updateUserContext(source) {
|
|
119
|
+
if (source.type !== "web") {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const nextContext = source.user
|
|
123
|
+
? { name: source.user.name, role: source.user.role }
|
|
124
|
+
: undefined;
|
|
125
|
+
if (sameUserContext(currentUserContext, nextContext)) {
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
currentUserContext = nextContext;
|
|
129
|
+
sessionMap.delete("default");
|
|
130
|
+
}
|
|
131
|
+
function updateRequestContext(source) {
|
|
132
|
+
if (source.type !== "web") {
|
|
133
|
+
currentAuthenticatedUser = undefined;
|
|
134
|
+
currentAuthorizationHeader = undefined;
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
currentAuthenticatedUser = source.user;
|
|
138
|
+
currentAuthorizationHeader = source.authorizationHeader?.trim() || undefined;
|
|
139
|
+
if (source.user) {
|
|
140
|
+
setState(LAST_AUTHENTICATED_USER_KEY, JSON.stringify(source.user));
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
/** Feed an agent task result into the orchestrator as a new turn. */
|
|
144
|
+
export function feedAgentResult(taskId, agentSlug, result) {
|
|
145
|
+
const prompt = `[Agent task completed] @${agentSlug} finished task ${taskId}:\n\n${result}`;
|
|
146
|
+
const sessionKey = getTaskSessionKey(taskId);
|
|
147
|
+
sendToOrchestrator(prompt, { type: "background", sessionKey }, (text, done) => {
|
|
148
|
+
if (done && proactiveNotifyFn) {
|
|
149
|
+
proactiveNotifyFn(text);
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
function sleep(ms) {
|
|
154
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
155
|
+
}
|
|
156
|
+
/** Ensure the SDK client is connected, resetting if necessary. Coalesces concurrent resets. */
|
|
157
|
+
let resetPromise;
|
|
158
|
+
async function ensureClient() {
|
|
159
|
+
if (copilotClient && copilotClient.getState() === "connected") {
|
|
160
|
+
return copilotClient;
|
|
161
|
+
}
|
|
162
|
+
if (!resetPromise) {
|
|
163
|
+
console.log(`[chapterhouse] Client not connected (state: ${copilotClient?.getState() ?? "null"}), resetting…`);
|
|
164
|
+
resetPromise = resetClient().then((c) => {
|
|
165
|
+
console.log(`[chapterhouse] Client reset successful, state: ${c.getState()}`);
|
|
166
|
+
copilotClient = c;
|
|
167
|
+
return c;
|
|
168
|
+
}).finally(() => { resetPromise = undefined; });
|
|
169
|
+
}
|
|
170
|
+
return resetPromise;
|
|
171
|
+
}
|
|
172
|
+
/** Start periodic health check that proactively reconnects the client. */
|
|
173
|
+
function startHealthCheck() {
|
|
174
|
+
if (healthCheckTimer)
|
|
175
|
+
return;
|
|
176
|
+
healthCheckTimer = setInterval(async () => {
|
|
177
|
+
if (!copilotClient)
|
|
178
|
+
return;
|
|
179
|
+
try {
|
|
180
|
+
const state = copilotClient.getState();
|
|
181
|
+
if (state !== "connected") {
|
|
182
|
+
console.log(`[chapterhouse] Health check: client state is '${state}', resetting…`);
|
|
183
|
+
await ensureClient();
|
|
184
|
+
// Session may need recovery after client reset
|
|
185
|
+
sessionMap.clear();
|
|
186
|
+
sessionModelMap.clear();
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
catch (err) {
|
|
190
|
+
console.error(`[chapterhouse] Health check error:`, err instanceof Error ? err.message : err);
|
|
191
|
+
}
|
|
192
|
+
}, HEALTH_CHECK_INTERVAL_MS);
|
|
193
|
+
}
|
|
194
|
+
/** Ensure a session exists for the given key, creating/resuming as needed. Concurrency-safe. */
|
|
195
|
+
async function ensureOrchestratorSession(sessionKey, projectRoot) {
|
|
196
|
+
const existing = sessionMap.get(sessionKey);
|
|
197
|
+
if (existing)
|
|
198
|
+
return existing;
|
|
199
|
+
// Coalesce concurrent callers for the same key
|
|
200
|
+
const inFlight = sessionCreatePromises.get(sessionKey);
|
|
201
|
+
if (inFlight)
|
|
202
|
+
return inFlight;
|
|
203
|
+
const promise = createOrResumeSession(sessionKey, projectRoot);
|
|
204
|
+
sessionCreatePromises.set(sessionKey, promise);
|
|
205
|
+
try {
|
|
206
|
+
const session = await promise;
|
|
207
|
+
sessionMap.set(sessionKey, session);
|
|
208
|
+
return session;
|
|
209
|
+
}
|
|
210
|
+
finally {
|
|
211
|
+
sessionCreatePromises.delete(sessionKey);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
/** Internal: actually create or resume a session (not concurrency-safe — use ensureOrchestratorSession). */
|
|
215
|
+
async function createOrResumeSession(sessionKey, projectRoot) {
|
|
216
|
+
const client = await ensureClient();
|
|
217
|
+
const { tools, mcpServers, skillDirectories } = getSessionConfig();
|
|
218
|
+
const isProjectSession = sessionKey.startsWith("project:");
|
|
219
|
+
const infiniteSessions = {
|
|
220
|
+
enabled: true,
|
|
221
|
+
backgroundCompactionThreshold: 0.80,
|
|
222
|
+
bufferExhaustionThreshold: 0.95,
|
|
223
|
+
};
|
|
224
|
+
// Build the correct system message for this session mode
|
|
225
|
+
let systemMessageContent;
|
|
226
|
+
if (isProjectSession && projectRoot) {
|
|
227
|
+
systemMessageContent = await getSquadCoordinatorSystemMessage(projectRoot);
|
|
228
|
+
}
|
|
229
|
+
else {
|
|
230
|
+
const memorySummary = getWikiSummary();
|
|
231
|
+
systemMessageContent = getOrchestratorSystemMessage({
|
|
232
|
+
...getSystemMessageOptions(memorySummary, isProjectSession ? projectRoot : undefined),
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
// Try to resume from copilot_sessions; fall back to legacy max_state key for the default session
|
|
236
|
+
const stored = getCopilotSession(sessionKey);
|
|
237
|
+
const savedSessionId = stored?.copilotSessionId ?? (sessionKey === "default" ? getState(ORCHESTRATOR_SESSION_KEY) : undefined);
|
|
238
|
+
if (savedSessionId) {
|
|
239
|
+
try {
|
|
240
|
+
console.log(`[chapterhouse] Resuming session [${sessionKey}] ${savedSessionId.slice(0, 8)}…`);
|
|
241
|
+
const session = await client.resumeSession(savedSessionId, {
|
|
242
|
+
model: config.copilotModel,
|
|
243
|
+
configDir: SESSIONS_DIR,
|
|
244
|
+
streaming: true,
|
|
245
|
+
systemMessage: { content: systemMessageContent },
|
|
246
|
+
tools,
|
|
247
|
+
mcpServers,
|
|
248
|
+
skillDirectories,
|
|
249
|
+
onPermissionRequest: orchestratorPermissionHandler,
|
|
250
|
+
infiniteSessions,
|
|
251
|
+
});
|
|
252
|
+
console.log(`[chapterhouse] Resumed session [${sessionKey}] successfully`);
|
|
253
|
+
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
254
|
+
sessionModelMap.set(sessionKey, config.copilotModel);
|
|
255
|
+
return session;
|
|
256
|
+
}
|
|
257
|
+
catch (err) {
|
|
258
|
+
console.log(`[chapterhouse] Could not resume session [${sessionKey}]: ${err instanceof Error ? err.message : err}. Creating new.`);
|
|
259
|
+
if (sessionKey === "default")
|
|
260
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
// Create a fresh session
|
|
264
|
+
console.log(`[chapterhouse] Creating new session [${sessionKey}]`);
|
|
265
|
+
const session = await client.createSession({
|
|
266
|
+
model: config.copilotModel,
|
|
267
|
+
configDir: SESSIONS_DIR,
|
|
268
|
+
streaming: true,
|
|
269
|
+
systemMessage: { content: systemMessageContent },
|
|
270
|
+
tools,
|
|
271
|
+
mcpServers,
|
|
272
|
+
skillDirectories,
|
|
273
|
+
onPermissionRequest: orchestratorPermissionHandler,
|
|
274
|
+
infiniteSessions,
|
|
275
|
+
});
|
|
276
|
+
console.log(`[chapterhouse] Created session [${sessionKey}] ${session.sessionId.slice(0, 8)}…`);
|
|
277
|
+
upsertCopilotSession(sessionKey, isProjectSession ? "project" : "default", session.sessionId, projectRoot, config.copilotModel);
|
|
278
|
+
// Backward compat: also persist the default session to the legacy state key
|
|
279
|
+
if (sessionKey === "default")
|
|
280
|
+
setState(ORCHESTRATOR_SESSION_KEY, session.sessionId);
|
|
281
|
+
sessionModelMap.set(sessionKey, config.copilotModel);
|
|
282
|
+
return session;
|
|
283
|
+
}
|
|
284
|
+
export async function initOrchestrator(client) {
|
|
285
|
+
copilotClient = client;
|
|
286
|
+
const { mcpServers, skillDirectories } = getSessionConfig();
|
|
287
|
+
// Initialize agent system
|
|
288
|
+
ensureDefaultAgents();
|
|
289
|
+
const agents = loadAgents();
|
|
290
|
+
console.log(`[chapterhouse] Loaded ${agents.length} agent(s): ${agents.map((a) => `@${a.slug}`).join(", ") || "(none)"}`);
|
|
291
|
+
// Validate configured model against available models
|
|
292
|
+
try {
|
|
293
|
+
const models = await client.listModels();
|
|
294
|
+
const configured = config.copilotModel;
|
|
295
|
+
const isAvailable = models.some((m) => m.id === configured);
|
|
296
|
+
if (!isAvailable) {
|
|
297
|
+
console.log(`[chapterhouse] ⚠️ Configured model '${configured}' is not available. Falling back to '${DEFAULT_MODEL}'.`);
|
|
298
|
+
config.copilotModel = DEFAULT_MODEL;
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
catch (err) {
|
|
302
|
+
console.log(`[chapterhouse] Could not validate model (will use '${config.copilotModel}' as-is): ${err instanceof Error ? err.message : err}`);
|
|
303
|
+
}
|
|
304
|
+
console.log(`[chapterhouse] Loading ${Object.keys(mcpServers).length} MCP server(s): ${Object.keys(mcpServers).join(", ") || "(none)"}`);
|
|
305
|
+
console.log(`[chapterhouse] Skill directories: ${skillDirectories.join(", ") || "(none)"}`);
|
|
306
|
+
console.log(`[chapterhouse] Persistent session mode — conversation history maintained by SDK`);
|
|
307
|
+
startHealthCheck();
|
|
308
|
+
// Eagerly create/resume the default orchestrator session
|
|
309
|
+
try {
|
|
310
|
+
await ensureOrchestratorSession("default");
|
|
311
|
+
}
|
|
312
|
+
catch (err) {
|
|
313
|
+
console.error(`[chapterhouse] Failed to create initial session (will retry on first message):`, err instanceof Error ? err.message : err);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
/** How long to wait for the orchestrator to finish a turn (10 min). */
|
|
317
|
+
const ORCHESTRATOR_TIMEOUT_MS = 600_000;
|
|
318
|
+
/** Send a prompt on a session identified by sessionKey, return the response. */
|
|
319
|
+
async function executeOnSession(sessionKey, prompt, callback, attachments, onActivity) {
|
|
320
|
+
const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
|
|
321
|
+
const session = await ensureOrchestratorSession(sessionKey, projectRoot);
|
|
322
|
+
currentProcessingSessionKey = sessionKey;
|
|
323
|
+
currentCallback = callback;
|
|
324
|
+
currentActivityCallback = onActivity;
|
|
325
|
+
let accumulated = "";
|
|
326
|
+
let toolCallExecuted = false;
|
|
327
|
+
let toolCallCount = 0;
|
|
328
|
+
const unsubToolDone = session.on("tool.execution_complete", (event) => {
|
|
329
|
+
toolCallExecuted = true;
|
|
330
|
+
toolCallCount++;
|
|
331
|
+
if (onActivity) {
|
|
332
|
+
const data = event.data;
|
|
333
|
+
const result = data.result;
|
|
334
|
+
const resultPreview = typeof result?.content === "string" ? result.content.slice(0, 400) : undefined;
|
|
335
|
+
const detailedContent = typeof result?.detailedContent === "string"
|
|
336
|
+
? result.detailedContent
|
|
337
|
+
: typeof result?.content === "string"
|
|
338
|
+
? result.content
|
|
339
|
+
: undefined;
|
|
340
|
+
onActivity({
|
|
341
|
+
kind: "tool_complete",
|
|
342
|
+
toolCallId: data.toolCallId,
|
|
343
|
+
success: data.success,
|
|
344
|
+
resultPreview,
|
|
345
|
+
detailedContent,
|
|
346
|
+
});
|
|
347
|
+
}
|
|
348
|
+
});
|
|
349
|
+
const unsubToolStart = onActivity
|
|
350
|
+
? session.on("tool.execution_start", (event) => {
|
|
351
|
+
const data = event.data;
|
|
352
|
+
onActivity({
|
|
353
|
+
kind: "tool_start",
|
|
354
|
+
toolCallId: data.toolCallId,
|
|
355
|
+
toolName: data.toolName,
|
|
356
|
+
mcpServerName: data.mcpServerName,
|
|
357
|
+
arguments: data.arguments,
|
|
358
|
+
});
|
|
359
|
+
})
|
|
360
|
+
: () => { };
|
|
361
|
+
const unsubReasoning = onActivity
|
|
362
|
+
? session.on("assistant.reasoning_delta", (event) => {
|
|
363
|
+
onActivity({
|
|
364
|
+
kind: "thinking_delta",
|
|
365
|
+
reasoningId: event.data.reasoningId,
|
|
366
|
+
deltaContent: event.data.deltaContent,
|
|
367
|
+
});
|
|
368
|
+
})
|
|
369
|
+
: () => { };
|
|
370
|
+
const unsubSubStart = onActivity
|
|
371
|
+
? session.on("subagent.started", (event) => {
|
|
372
|
+
const data = event.data;
|
|
373
|
+
onActivity({
|
|
374
|
+
kind: "subagent_started",
|
|
375
|
+
toolCallId: data.toolCallId,
|
|
376
|
+
agentName: data.agentName,
|
|
377
|
+
agentDisplayName: data.agentDisplayName,
|
|
378
|
+
agentDescription: data.agentDescription,
|
|
379
|
+
});
|
|
380
|
+
})
|
|
381
|
+
: () => { };
|
|
382
|
+
const unsubSubDone = onActivity
|
|
383
|
+
? session.on("subagent.completed", (event) => {
|
|
384
|
+
const data = event.data;
|
|
385
|
+
onActivity({
|
|
386
|
+
kind: "subagent_completed",
|
|
387
|
+
toolCallId: data.toolCallId,
|
|
388
|
+
agentName: data.agentName,
|
|
389
|
+
agentDisplayName: data.agentDisplayName,
|
|
390
|
+
durationMs: data.durationMs,
|
|
391
|
+
});
|
|
392
|
+
})
|
|
393
|
+
: () => { };
|
|
394
|
+
const unsubSubFail = onActivity
|
|
395
|
+
? session.on("subagent.failed", (event) => {
|
|
396
|
+
const data = event.data;
|
|
397
|
+
onActivity({
|
|
398
|
+
kind: "subagent_failed",
|
|
399
|
+
toolCallId: data.toolCallId,
|
|
400
|
+
agentName: data.agentName,
|
|
401
|
+
agentDisplayName: data.agentDisplayName,
|
|
402
|
+
error: data.error,
|
|
403
|
+
});
|
|
404
|
+
})
|
|
405
|
+
: () => { };
|
|
406
|
+
const unsubDelta = session.on("assistant.message_delta", (event) => {
|
|
407
|
+
// After a tool call completes, ensure a line break separates the text blocks
|
|
408
|
+
// so they don't visually run together in the rendered chat.
|
|
409
|
+
if (toolCallExecuted && accumulated.length > 0 && !accumulated.endsWith("\n")) {
|
|
410
|
+
accumulated += "\n";
|
|
411
|
+
}
|
|
412
|
+
toolCallExecuted = false;
|
|
413
|
+
accumulated += event.data.deltaContent;
|
|
414
|
+
callback(accumulated, false);
|
|
415
|
+
});
|
|
416
|
+
try {
|
|
417
|
+
const result = await session.sendAndWait({ prompt, ...(attachments && attachments.length > 0 ? { attachments } : {}) }, ORCHESTRATOR_TIMEOUT_MS);
|
|
418
|
+
const finalContent = result?.data?.content || accumulated || "(No response)";
|
|
419
|
+
return finalContent;
|
|
420
|
+
}
|
|
421
|
+
catch (err) {
|
|
422
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
423
|
+
// On timeout, never throw — the message was already sent to the persistent
|
|
424
|
+
// session and may have been (partially) processed. Return what we have.
|
|
425
|
+
if (/timeout/i.test(msg)) {
|
|
426
|
+
if (accumulated.length > 0) {
|
|
427
|
+
console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s but have ${accumulated.length} chars — returning partial response`);
|
|
428
|
+
return accumulated;
|
|
429
|
+
}
|
|
430
|
+
// No text yet but tool calls ran — the session is working in the background
|
|
431
|
+
// (e.g. delegate_to_agent dispatched). Don't error out.
|
|
432
|
+
if (toolCallCount > 0) {
|
|
433
|
+
console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s — ${toolCallCount} tool call(s) executed but no text yet. Session is still working.`);
|
|
434
|
+
return "I'm still working on this — I've started processing but it's taking longer than expected. I'll send you the results when I'm done.";
|
|
435
|
+
}
|
|
436
|
+
// No text, no tool calls — the session is truly stuck
|
|
437
|
+
console.log(`[chapterhouse] Timeout after ${ORCHESTRATOR_TIMEOUT_MS / 1000}s with no activity. Session may be stuck.`);
|
|
438
|
+
return "Sorry, that request timed out before I could start working on it. Try again or break it into smaller pieces?";
|
|
439
|
+
}
|
|
440
|
+
// If the session is broken, invalidate it so it's recreated on next attempt
|
|
441
|
+
if (/closed|destroy|disposed|invalid|expired|not found/i.test(msg)) {
|
|
442
|
+
console.log(`[chapterhouse] Session [${sessionKey}] appears dead, will recreate: ${msg}`);
|
|
443
|
+
sessionMap.delete(sessionKey);
|
|
444
|
+
sessionModelMap.delete(sessionKey);
|
|
445
|
+
if (sessionKey === "default")
|
|
446
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
447
|
+
}
|
|
448
|
+
throw err;
|
|
449
|
+
}
|
|
450
|
+
finally {
|
|
451
|
+
unsubDelta();
|
|
452
|
+
unsubToolDone();
|
|
453
|
+
unsubToolStart();
|
|
454
|
+
unsubReasoning();
|
|
455
|
+
unsubSubStart();
|
|
456
|
+
unsubSubDone();
|
|
457
|
+
unsubSubFail();
|
|
458
|
+
currentCallback = undefined;
|
|
459
|
+
currentActivityCallback = undefined;
|
|
460
|
+
currentProcessingSessionKey = undefined;
|
|
461
|
+
}
|
|
462
|
+
}
|
|
463
|
+
/** Process the message queue one at a time. */
|
|
464
|
+
async function processQueue() {
|
|
465
|
+
if (processing) {
|
|
466
|
+
if (messageQueue.length > 0) {
|
|
467
|
+
console.log(`[chapterhouse] Message queued (${messageQueue.length} waiting — orchestrator is busy)`);
|
|
468
|
+
}
|
|
469
|
+
return;
|
|
470
|
+
}
|
|
471
|
+
processing = true;
|
|
472
|
+
while (messageQueue.length > 0) {
|
|
473
|
+
const item = messageQueue.shift();
|
|
474
|
+
currentSourceChannel = item.sourceChannel;
|
|
475
|
+
currentChannelKey = item.channelKey;
|
|
476
|
+
const { sessionKey } = item;
|
|
477
|
+
try {
|
|
478
|
+
let result;
|
|
479
|
+
if (item.targetAgent && item.targetAgent !== "chapterhouse") {
|
|
480
|
+
// @mention switches the active agent — route through the session
|
|
481
|
+
setActiveAgent(item.channelKey || "default", item.targetAgent);
|
|
482
|
+
result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
|
|
483
|
+
}
|
|
484
|
+
else {
|
|
485
|
+
// Route the model before executing
|
|
486
|
+
const currentModel = sessionModelMap.get(sessionKey) ?? config.copilotModel;
|
|
487
|
+
const routeResult = await resolveModel(item.prompt, currentModel, recentTiers);
|
|
488
|
+
if (routeResult.switched) {
|
|
489
|
+
console.log(`[chapterhouse] Auto: switching to ${routeResult.model} (${routeResult.overrideName || routeResult.tier})`);
|
|
490
|
+
config.copilotModel = routeResult.model;
|
|
491
|
+
const existingSession = sessionMap.get(sessionKey);
|
|
492
|
+
if (existingSession) {
|
|
493
|
+
try {
|
|
494
|
+
await existingSession.setModel(routeResult.model);
|
|
495
|
+
sessionModelMap.set(sessionKey, routeResult.model);
|
|
496
|
+
console.log(`[chapterhouse] Model switched in-place via setModel() for [${sessionKey}]`);
|
|
497
|
+
}
|
|
498
|
+
catch (err) {
|
|
499
|
+
console.log(`[chapterhouse] setModel() failed for [${sessionKey}], will recreate: ${err instanceof Error ? err.message : err}`);
|
|
500
|
+
sessionMap.delete(sessionKey);
|
|
501
|
+
if (sessionKey === "default")
|
|
502
|
+
deleteState(ORCHESTRATOR_SESSION_KEY);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
if (routeResult.tier) {
|
|
507
|
+
recentTiers.push(routeResult.tier);
|
|
508
|
+
if (recentTiers.length > 5)
|
|
509
|
+
recentTiers = recentTiers.slice(-5);
|
|
510
|
+
}
|
|
511
|
+
lastRouteResult = routeResult;
|
|
512
|
+
result = await executeOnSession(sessionKey, item.prompt, item.callback, item.attachments, item.onActivity);
|
|
513
|
+
}
|
|
514
|
+
item.resolve(result);
|
|
515
|
+
}
|
|
516
|
+
catch (err) {
|
|
517
|
+
item.reject(err);
|
|
518
|
+
}
|
|
519
|
+
currentSourceChannel = undefined;
|
|
520
|
+
currentChannelKey = undefined;
|
|
521
|
+
}
|
|
522
|
+
processing = false;
|
|
523
|
+
}
|
|
524
|
+
function isRecoverableError(err) {
|
|
525
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
526
|
+
// Timeouts are NOT retryable on a persistent session — the message was already
|
|
527
|
+
// sent and likely processed; re-sending creates "duplicate" responses.
|
|
528
|
+
if (/timeout/i.test(msg))
|
|
529
|
+
return false;
|
|
530
|
+
return /disconnect|connection|EPIPE|ECONNRESET|ECONNREFUSED|socket|closed|ENOENT|spawn|not found|expired|stale/i.test(msg);
|
|
531
|
+
}
|
|
532
|
+
export async function sendToOrchestrator(prompt, source, callback, attachments, onActivity) {
|
|
533
|
+
updateUserContext(source);
|
|
534
|
+
updateRequestContext(source);
|
|
535
|
+
const sourceLabel = source.type === "web" ? "web" : "background";
|
|
536
|
+
logMessage("in", sourceLabel, prompt);
|
|
537
|
+
// Derive the session key: project sessions come from web messages with a projectPath;
|
|
538
|
+
// background completions carry their own sessionKey; everything else is "default".
|
|
539
|
+
let sessionKey;
|
|
540
|
+
if (source.type === "web" && source.projectPath && config.squadEnabled) {
|
|
541
|
+
sessionKey = "project:" + normalizeProjectPath(source.projectPath);
|
|
542
|
+
// Keep the legacy channel-project map in sync for tools that read it
|
|
543
|
+
setChannelProject(source.connectionId, normalizeProjectPath(source.projectPath));
|
|
544
|
+
}
|
|
545
|
+
else if (source.type === "background" && source.sessionKey) {
|
|
546
|
+
sessionKey = source.sessionKey;
|
|
547
|
+
}
|
|
548
|
+
else {
|
|
549
|
+
sessionKey = "default";
|
|
550
|
+
}
|
|
551
|
+
const channelKey = source.type === "web" ? source.connectionId : "default";
|
|
552
|
+
// Pass projectRoot to parseAtMention only for project sessions so the default
|
|
553
|
+
// chat does not get Squad roster injection.
|
|
554
|
+
const projectRoot = sessionKey.startsWith("project:") ? sessionKey.slice("project:".length) : undefined;
|
|
555
|
+
// Parse @mention routing (e.g., "@coder fix the bug" → target "coder")
|
|
556
|
+
const mention = parseAtMention(prompt, projectRoot);
|
|
557
|
+
const targetAgent = mention?.agentSlug;
|
|
558
|
+
const routedPrompt = mention ? mention.message : prompt;
|
|
559
|
+
// Tag the prompt with its source channel
|
|
560
|
+
const taggedPrompt = source.type === "background"
|
|
561
|
+
? routedPrompt
|
|
562
|
+
: `[via ${sourceLabel}] ${routedPrompt}`;
|
|
563
|
+
// Log role: background events are "system", user messages are "user"
|
|
564
|
+
const logRole = source.type === "background" ? "system" : "user";
|
|
565
|
+
// Determine the source channel for agent origin tracking
|
|
566
|
+
const sourceChannel = source.type === "web" ? "web" : undefined;
|
|
567
|
+
// Enqueue and process
|
|
568
|
+
void (async () => {
|
|
569
|
+
for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
|
|
570
|
+
try {
|
|
571
|
+
const finalContent = await new Promise((resolve, reject) => {
|
|
572
|
+
messageQueue.push({ prompt: taggedPrompt, attachments, callback, onActivity, sourceChannel, targetAgent, channelKey, sessionKey, resolve, reject });
|
|
573
|
+
processQueue();
|
|
574
|
+
});
|
|
575
|
+
// Deliver response to user FIRST, then log best-effort
|
|
576
|
+
callback(finalContent, true);
|
|
577
|
+
try {
|
|
578
|
+
logMessage("out", sourceLabel, finalContent);
|
|
579
|
+
}
|
|
580
|
+
catch { /* best-effort */ }
|
|
581
|
+
// Log both sides of the conversation, scoped to the session
|
|
582
|
+
try {
|
|
583
|
+
logConversation(logRole, prompt, sourceLabel, sessionKey);
|
|
584
|
+
}
|
|
585
|
+
catch { /* best-effort */ }
|
|
586
|
+
try {
|
|
587
|
+
logConversation("assistant", finalContent, sourceLabel, sessionKey);
|
|
588
|
+
}
|
|
589
|
+
catch { /* best-effort */ }
|
|
590
|
+
// Episodic memory: if enough turns have accumulated since the last
|
|
591
|
+
// summary, kick off a background write. Fire-and-forget — never blocks
|
|
592
|
+
// the user reply path.
|
|
593
|
+
if (copilotClient) {
|
|
594
|
+
maybeWriteEpisode(copilotClient).catch((err) => {
|
|
595
|
+
console.error("[chapterhouse] Episode write failed (non-fatal):", err instanceof Error ? err.message : err);
|
|
596
|
+
});
|
|
597
|
+
}
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
catch (err) {
|
|
601
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
602
|
+
// Don't retry cancelled messages
|
|
603
|
+
if (/cancelled|abort/i.test(msg)) {
|
|
604
|
+
return;
|
|
605
|
+
}
|
|
606
|
+
if (isRecoverableError(err) && attempt < MAX_RETRIES) {
|
|
607
|
+
const delay = RECONNECT_DELAYS_MS[Math.min(attempt, RECONNECT_DELAYS_MS.length - 1)];
|
|
608
|
+
console.error(`[chapterhouse] Recoverable error: ${msg}. Retry ${attempt + 1}/${MAX_RETRIES} after ${delay}ms…`);
|
|
609
|
+
await sleep(delay);
|
|
610
|
+
// Reset client before retry in case the connection is stale
|
|
611
|
+
try {
|
|
612
|
+
await ensureClient();
|
|
613
|
+
}
|
|
614
|
+
catch { /* will fail again on next attempt */ }
|
|
615
|
+
continue;
|
|
616
|
+
}
|
|
617
|
+
console.error(`[chapterhouse] Error processing message: ${msg}`);
|
|
618
|
+
callback(`Error: ${msg}`, true);
|
|
619
|
+
return;
|
|
620
|
+
}
|
|
621
|
+
}
|
|
622
|
+
})();
|
|
623
|
+
}
|
|
624
|
+
/** Cancel the in-flight message and drain the queue. */
|
|
625
|
+
export async function cancelCurrentMessage() {
|
|
626
|
+
// Drain any queued messages
|
|
627
|
+
const drained = messageQueue.length;
|
|
628
|
+
while (messageQueue.length > 0) {
|
|
629
|
+
const item = messageQueue.shift();
|
|
630
|
+
item.reject(new Error("Cancelled"));
|
|
631
|
+
}
|
|
632
|
+
// Abort the active session request
|
|
633
|
+
const activeSession = currentProcessingSessionKey ? sessionMap.get(currentProcessingSessionKey) : undefined;
|
|
634
|
+
if (activeSession && currentCallback) {
|
|
635
|
+
try {
|
|
636
|
+
await activeSession.abort();
|
|
637
|
+
console.log(`[chapterhouse] Aborted in-flight request on [${currentProcessingSessionKey}]`);
|
|
638
|
+
return true;
|
|
639
|
+
}
|
|
640
|
+
catch (err) {
|
|
641
|
+
console.error(`[chapterhouse] Abort failed:`, err instanceof Error ? err.message : err);
|
|
642
|
+
}
|
|
643
|
+
}
|
|
644
|
+
return drained > 0;
|
|
645
|
+
}
|
|
646
|
+
/** Switch the model on the live default orchestrator session without destroying it. */
|
|
647
|
+
export function switchSessionModel(newModel) {
|
|
648
|
+
const session = sessionMap.get("default");
|
|
649
|
+
if (session) {
|
|
650
|
+
return session.setModel(newModel).then(() => {
|
|
651
|
+
sessionModelMap.set("default", newModel);
|
|
652
|
+
});
|
|
653
|
+
}
|
|
654
|
+
return Promise.resolve();
|
|
655
|
+
}
|
|
656
|
+
/** Return a snapshot of currently running workers for API/UI consumers. */
|
|
657
|
+
export function getAgentInfo() {
|
|
658
|
+
const allTasks = getActiveTasks().filter((t) => t.status === "running");
|
|
659
|
+
const registry = getAgentRegistry();
|
|
660
|
+
return allTasks.map((t) => {
|
|
661
|
+
const agent = registry.find((a) => a.slug === t.agentSlug);
|
|
662
|
+
return {
|
|
663
|
+
slug: t.agentSlug,
|
|
664
|
+
name: agent?.name || t.agentSlug,
|
|
665
|
+
model: agent?.model || "unknown",
|
|
666
|
+
taskId: t.taskId,
|
|
667
|
+
description: t.description,
|
|
668
|
+
};
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
/** Clean up on shutdown/restart. */
|
|
672
|
+
export async function shutdownAgents() {
|
|
673
|
+
for (const [key, session] of sessionMap) {
|
|
674
|
+
try {
|
|
675
|
+
await session.disconnect();
|
|
676
|
+
}
|
|
677
|
+
catch (err) {
|
|
678
|
+
console.error(`[orchestrator] Error disconnecting session "${key}" during shutdown:`, err instanceof Error ? err.message : err);
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
sessionMap.clear();
|
|
682
|
+
sessionModelMap.clear();
|
|
683
|
+
await clearActiveTasks();
|
|
684
|
+
}
|
|
685
|
+
//# sourceMappingURL=orchestrator.js.map
|