quadwork 1.2.2 → 1.2.4
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/out/404.html +1 -1
- package/out/__next.__PAGE__.txt +1 -1
- package/out/__next._full.txt +1 -1
- package/out/__next._head.txt +1 -1
- package/out/__next._index.txt +1 -1
- package/out/__next._tree.txt +1 -1
- package/out/_not-found/__next._full.txt +1 -1
- package/out/_not-found/__next._head.txt +1 -1
- package/out/_not-found/__next._index.txt +1 -1
- package/out/_not-found/__next._not-found.__PAGE__.txt +1 -1
- package/out/_not-found/__next._not-found.txt +1 -1
- package/out/_not-found/__next._tree.txt +1 -1
- package/out/_not-found.html +1 -1
- package/out/_not-found.txt +1 -1
- package/out/app-shell/__next._full.txt +1 -1
- package/out/app-shell/__next._head.txt +1 -1
- package/out/app-shell/__next._index.txt +1 -1
- package/out/app-shell/__next._tree.txt +1 -1
- package/out/app-shell/__next.app-shell.__PAGE__.txt +1 -1
- package/out/app-shell/__next.app-shell.txt +1 -1
- package/out/app-shell.html +1 -1
- package/out/app-shell.txt +1 -1
- package/out/index.html +1 -1
- package/out/index.txt +1 -1
- package/out/project/_/__next._full.txt +1 -1
- package/out/project/_/__next._head.txt +1 -1
- package/out/project/_/__next._index.txt +1 -1
- package/out/project/_/__next._tree.txt +1 -1
- package/out/project/_/__next.project.$d$id.__PAGE__.txt +1 -1
- package/out/project/_/__next.project.$d$id.txt +1 -1
- package/out/project/_/__next.project.txt +1 -1
- package/out/project/_/memory/__next._full.txt +1 -1
- package/out/project/_/memory/__next._head.txt +1 -1
- package/out/project/_/memory/__next._index.txt +1 -1
- package/out/project/_/memory/__next._tree.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.memory.__PAGE__.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.memory.txt +1 -1
- package/out/project/_/memory/__next.project.$d$id.txt +1 -1
- package/out/project/_/memory/__next.project.txt +1 -1
- package/out/project/_/memory.html +1 -1
- package/out/project/_/memory.txt +1 -1
- package/out/project/_/queue/__next._full.txt +1 -1
- package/out/project/_/queue/__next._head.txt +1 -1
- package/out/project/_/queue/__next._index.txt +1 -1
- package/out/project/_/queue/__next._tree.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.__PAGE__.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.queue.txt +1 -1
- package/out/project/_/queue/__next.project.$d$id.txt +1 -1
- package/out/project/_/queue/__next.project.txt +1 -1
- package/out/project/_/queue.html +1 -1
- package/out/project/_/queue.txt +1 -1
- package/out/project/_.html +1 -1
- package/out/project/_.txt +1 -1
- package/out/settings/__next._full.txt +1 -1
- package/out/settings/__next._head.txt +1 -1
- package/out/settings/__next._index.txt +1 -1
- package/out/settings/__next._tree.txt +1 -1
- package/out/settings/__next.settings.__PAGE__.txt +1 -1
- package/out/settings/__next.settings.txt +1 -1
- package/out/settings.html +1 -1
- package/out/settings.txt +1 -1
- package/out/setup/__next._full.txt +1 -1
- package/out/setup/__next._head.txt +1 -1
- package/out/setup/__next._index.txt +1 -1
- package/out/setup/__next._tree.txt +1 -1
- package/out/setup/__next.setup.__PAGE__.txt +1 -1
- package/out/setup/__next.setup.txt +1 -1
- package/out/setup.html +1 -1
- package/out/setup.txt +1 -1
- package/package.json +1 -1
- package/server/agentchattr-registry.js +171 -0
- package/server/index.js +301 -25
- package/server/queue-watcher.js +114 -0
- package/server/routes.js +38 -0
- /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → BUxEn3tvHfTVe2bSrJDyA}/_buildManifest.js +0 -0
- /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → BUxEn3tvHfTVe2bSrJDyA}/_clientMiddlewareManifest.js +0 -0
- /package/out/_next/static/{x1j09EJSQ1T6SDM0qG0LZ → BUxEn3tvHfTVe2bSrJDyA}/_ssgManifest.js +0 -0
package/server/index.js
CHANGED
|
@@ -8,6 +8,8 @@ const pty = require("node-pty");
|
|
|
8
8
|
const { spawn } = require("child_process");
|
|
9
9
|
const { readConfig, resolveAgentCwd, resolveAgentCommand, resolveProjectChattr, resolveChattrSpawn, syncChattrToken, CONFIG_PATH } = require("./config");
|
|
10
10
|
const routes = require("./routes");
|
|
11
|
+
const { waitForAgentChattrReady, registerAgent, deregisterAgent, startHeartbeat, stopHeartbeat } = require("./agentchattr-registry");
|
|
12
|
+
const { startQueueWatcher, stopQueueWatcher } = require("./queue-watcher");
|
|
11
13
|
|
|
12
14
|
const net = require("net");
|
|
13
15
|
const config = readConfig();
|
|
@@ -145,14 +147,20 @@ function startMcpProxy(projectId, agentId, upstreamUrl, token) {
|
|
|
145
147
|
const existing = mcpProxies.get(key);
|
|
146
148
|
if (existing) return Promise.resolve(`http://127.0.0.1:${existing.port}/mcp`);
|
|
147
149
|
|
|
150
|
+
// #394 / quadwork#253: token is mutable so the 409 recovery path can
|
|
151
|
+
// swap it via updateMcpProxyToken without rebinding the listener —
|
|
152
|
+
// Codex was launched with a fixed proxy URL on an ephemeral port and
|
|
153
|
+
// can't be told to use a new one mid-flight.
|
|
154
|
+
const tokenRef = { current: token };
|
|
148
155
|
return new Promise((resolve, reject) => {
|
|
149
156
|
const proxyServer = http.createServer((req, res) => {
|
|
150
157
|
const parsedUrl = new URL(req.url, `http://127.0.0.1`);
|
|
151
158
|
const targetUrl = `${upstreamUrl}${parsedUrl.pathname}${parsedUrl.search}`;
|
|
152
159
|
const headers = { ...req.headers, host: new URL(upstreamUrl).host };
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
headers["
|
|
160
|
+
const tok = tokenRef.current;
|
|
161
|
+
if (tok) {
|
|
162
|
+
headers["authorization"] = `Bearer ${tok}`;
|
|
163
|
+
headers["x-agent-token"] = tok;
|
|
156
164
|
}
|
|
157
165
|
delete headers["content-length"];
|
|
158
166
|
|
|
@@ -179,12 +187,27 @@ function startMcpProxy(projectId, agentId, upstreamUrl, token) {
|
|
|
179
187
|
proxyServer.on("error", (err) => reject(err));
|
|
180
188
|
proxyServer.listen(0, "127.0.0.1", () => {
|
|
181
189
|
const port = proxyServer.address().port;
|
|
182
|
-
mcpProxies.set(key, { server: proxyServer, port });
|
|
190
|
+
mcpProxies.set(key, { server: proxyServer, port, tokenRef });
|
|
183
191
|
resolve(`http://127.0.0.1:${port}/mcp`);
|
|
184
192
|
});
|
|
185
193
|
});
|
|
186
194
|
}
|
|
187
195
|
|
|
196
|
+
/**
|
|
197
|
+
* Swap the bearer token of a running MCP proxy in place. Used by the
|
|
198
|
+
* sub-D 409 recovery path: rebinding the listener would change the
|
|
199
|
+
* ephemeral port and the running Codex process is pinned to the
|
|
200
|
+
* original URL, so we mutate the closure-captured tokenRef instead.
|
|
201
|
+
* Returns true if a proxy existed and was updated.
|
|
202
|
+
*/
|
|
203
|
+
function updateMcpProxyToken(projectId, agentId, newToken) {
|
|
204
|
+
const key = `${projectId}/${agentId}`;
|
|
205
|
+
const proxy = mcpProxies.get(key);
|
|
206
|
+
if (!proxy || !proxy.tokenRef) return false;
|
|
207
|
+
proxy.tokenRef.current = newToken;
|
|
208
|
+
return true;
|
|
209
|
+
}
|
|
210
|
+
|
|
188
211
|
function stopMcpProxy(projectId, agentId) {
|
|
189
212
|
const key = `${projectId}/${agentId}`;
|
|
190
213
|
const proxy = mcpProxies.get(key);
|
|
@@ -207,6 +230,39 @@ const PERMISSION_FLAGS = {
|
|
|
207
230
|
* Generate a per-agent MCP config file for Claude (--mcp-config).
|
|
208
231
|
* Returns the absolute path to the written JSON file.
|
|
209
232
|
*/
|
|
233
|
+
/**
|
|
234
|
+
* Per-agent registration tokens persisted across QuadWork restarts so
|
|
235
|
+
* #242 stale-slot reclaim works after a crash. Without this the
|
|
236
|
+
* in-memory _tokenCache is empty on startup and the family-name
|
|
237
|
+
* deregister returns 403 (app.py:2123-2135).
|
|
238
|
+
*/
|
|
239
|
+
function _agentTokenPath(projectId, agentId) {
|
|
240
|
+
const configDir = path.join(os.homedir(), ".quadwork", projectId);
|
|
241
|
+
return path.join(configDir, `agent-token-${agentId}.txt`);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
function readPersistedAgentToken(projectId, agentId) {
|
|
245
|
+
try {
|
|
246
|
+
return fs.readFileSync(_agentTokenPath(projectId, agentId), "utf8").trim() || null;
|
|
247
|
+
} catch {
|
|
248
|
+
return null;
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
function writePersistedAgentToken(projectId, agentId, token) {
|
|
253
|
+
try {
|
|
254
|
+
const configDir = path.join(os.homedir(), ".quadwork", projectId);
|
|
255
|
+
if (!fs.existsSync(configDir)) fs.mkdirSync(configDir, { recursive: true });
|
|
256
|
+
fs.writeFileSync(_agentTokenPath(projectId, agentId), token, { mode: 0o600 });
|
|
257
|
+
} catch {
|
|
258
|
+
// non-fatal — stale-slot reclaim will degrade but registration still works
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
function clearPersistedAgentToken(projectId, agentId) {
|
|
263
|
+
try { fs.unlinkSync(_agentTokenPath(projectId, agentId)); } catch {}
|
|
264
|
+
}
|
|
265
|
+
|
|
210
266
|
function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
|
|
211
267
|
const os = require("os");
|
|
212
268
|
const configDir = path.join(os.homedir(), ".quadwork", projectId);
|
|
@@ -233,12 +289,16 @@ function writeMcpConfigFile(projectId, agentId, mcpHttpPort, token) {
|
|
|
233
289
|
async function buildAgentArgs(projectId, agentId) {
|
|
234
290
|
const cfg = readConfig();
|
|
235
291
|
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
236
|
-
if (!project) return [];
|
|
292
|
+
if (!project) return { args: [], acRegistrationName: null, acServerPort: null, acRegistrationToken: null, acInjectMode: null, acMcpHttpPort: null };
|
|
237
293
|
|
|
238
294
|
const agentCfg = project.agents?.[agentId] || {};
|
|
239
295
|
const command = agentCfg.command || "claude";
|
|
240
296
|
const cliBase = command.split("/").pop().split(" ")[0]; // extract base CLI name
|
|
241
297
|
const args = [];
|
|
298
|
+
let acRegistrationName = null;
|
|
299
|
+
let acServerPort = null;
|
|
300
|
+
let acRegistrationToken = null;
|
|
301
|
+
let acInjectMode = null;
|
|
242
302
|
|
|
243
303
|
// Permission bypass flags
|
|
244
304
|
if (agentCfg.auto_approve !== false) {
|
|
@@ -251,22 +311,67 @@ async function buildAgentArgs(projectId, agentId) {
|
|
|
251
311
|
const token = project.agentchattr_token;
|
|
252
312
|
if (mcpHttpPort) {
|
|
253
313
|
const injectMode = agentCfg.mcp_inject || (cliBase === "codex" ? "proxy_flag" : cliBase === "gemini" ? "env" : "flag");
|
|
314
|
+
acInjectMode = injectMode;
|
|
254
315
|
if (injectMode === "flag") {
|
|
255
|
-
// Claude/Kimi:
|
|
256
|
-
|
|
316
|
+
// Claude/Kimi: register with AgentChattr to obtain a per-agent
|
|
317
|
+
// token (#239 — session_token is browser auth, not MCP auth) and
|
|
318
|
+
// write that into the per-agent MCP config file.
|
|
319
|
+
const chattrInfo = resolveProjectChattr(projectId);
|
|
320
|
+
acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
|
|
321
|
+
await waitForAgentChattrReady(acServerPort);
|
|
322
|
+
// #242: best-effort deregister any stale registration of the
|
|
323
|
+
// canonical name (left over by a crashed previous QuadWork
|
|
324
|
+
// session) so the fresh register lands at slot 1 instead of
|
|
325
|
+
// head-2 / reviewer2-2. We need the previous agent's bearer
|
|
326
|
+
// token because app.py:2123 requires authenticated agent
|
|
327
|
+
// session for family names — load it from disk (persisted
|
|
328
|
+
// across restarts). Failures are non-fatal.
|
|
329
|
+
const stalePersistedToken = readPersistedAgentToken(projectId, agentId);
|
|
330
|
+
if (stalePersistedToken) {
|
|
331
|
+
await deregisterAgent(acServerPort, agentId, stalePersistedToken).catch(() => {});
|
|
332
|
+
clearPersistedAgentToken(projectId, agentId);
|
|
333
|
+
}
|
|
334
|
+
const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null);
|
|
335
|
+
if (!registration) {
|
|
336
|
+
throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
|
|
337
|
+
}
|
|
338
|
+
acRegistrationName = registration.name;
|
|
339
|
+
acRegistrationToken = registration.token;
|
|
340
|
+
writePersistedAgentToken(projectId, agentId, registration.token);
|
|
341
|
+
const mcpConfigPath = writeMcpConfigFile(projectId, agentId, mcpHttpPort, registration.token);
|
|
257
342
|
const flag = agentCfg.mcp_flag || "--mcp-config";
|
|
258
343
|
args.push(flag, mcpConfigPath);
|
|
259
344
|
} else if (injectMode === "proxy_flag") {
|
|
260
|
-
// Codex:
|
|
345
|
+
// Codex: register with AgentChattr first (#240) so the proxy
|
|
346
|
+
// injects a real per-agent token, not the global session token.
|
|
347
|
+
// Resolve via resolveProjectChattr so legacy/global-config
|
|
348
|
+
// projects without a per-project agentchattr_url still work.
|
|
349
|
+
const chattrInfo = resolveProjectChattr(projectId);
|
|
350
|
+
acServerPort = Number(new URL(chattrInfo.url).port) || 8300;
|
|
351
|
+
await waitForAgentChattrReady(acServerPort);
|
|
352
|
+
// #242: best-effort deregister stale canonical name first using
|
|
353
|
+
// the persisted bearer token from a previous session.
|
|
354
|
+
const stalePersistedToken = readPersistedAgentToken(projectId, agentId);
|
|
355
|
+
if (stalePersistedToken) {
|
|
356
|
+
await deregisterAgent(acServerPort, agentId, stalePersistedToken).catch(() => {});
|
|
357
|
+
clearPersistedAgentToken(projectId, agentId);
|
|
358
|
+
}
|
|
359
|
+
const registration = await registerAgent(acServerPort, agentId, agentCfg.display_name || null);
|
|
360
|
+
if (!registration) {
|
|
361
|
+
throw new Error(`Failed to register ${agentId}: ${registerAgent.lastError}`);
|
|
362
|
+
}
|
|
363
|
+
acRegistrationName = registration.name;
|
|
364
|
+
acRegistrationToken = registration.token;
|
|
365
|
+
writePersistedAgentToken(projectId, agentId, registration.token);
|
|
261
366
|
const upstreamUrl = `http://127.0.0.1:${mcpHttpPort}`;
|
|
262
|
-
const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, token);
|
|
367
|
+
const proxyUrl = await startMcpProxy(projectId, agentId, upstreamUrl, registration.token);
|
|
263
368
|
if (proxyUrl) {
|
|
264
369
|
args.push("-c", `mcp_servers.agentchattr.url="${proxyUrl}"`);
|
|
265
370
|
}
|
|
266
371
|
}
|
|
267
372
|
}
|
|
268
373
|
|
|
269
|
-
return args;
|
|
374
|
+
return { args, acRegistrationName, acServerPort, acRegistrationToken, acInjectMode, acMcpHttpPort: mcpHttpPort || null };
|
|
270
375
|
}
|
|
271
376
|
|
|
272
377
|
/**
|
|
@@ -305,6 +410,73 @@ function buildAgentEnv(projectId, agentId) {
|
|
|
305
410
|
return env;
|
|
306
411
|
}
|
|
307
412
|
|
|
413
|
+
/**
|
|
414
|
+
* #394 / quadwork#253: recover from a heartbeat 409 (AgentChattr was
|
|
415
|
+
* restarted, in-memory registry wiped, our token is now stale). Mirrors
|
|
416
|
+
* wrapper.py:732-741. Re-registers the running agent, swaps the
|
|
417
|
+
* tracked name/token on the live session so the heartbeat interval
|
|
418
|
+
* picks up the new credentials on its next tick, refreshes whichever
|
|
419
|
+
* MCP transport this agent uses (Claude config file vs Codex proxy),
|
|
420
|
+
* and restarts the queue watcher in case the assigned name changed
|
|
421
|
+
* (multi-instance slot bump).
|
|
422
|
+
*
|
|
423
|
+
* Best-effort: any failure here just means the next 5s heartbeat will
|
|
424
|
+
* fail again and we'll re-enter recovery — no tight retry loop because
|
|
425
|
+
* startHeartbeat guards re-entry with `recovering`.
|
|
426
|
+
*/
|
|
427
|
+
async function recoverFrom409(projectId, agentId, session) {
|
|
428
|
+
if (!session.acServerPort) return;
|
|
429
|
+
const cfg = readConfig();
|
|
430
|
+
const project = cfg.projects?.find((p) => p.id === projectId);
|
|
431
|
+
const agentCfg = project?.agents?.[agentId] || {};
|
|
432
|
+
// AC may need a moment to come back up after a restart — wait briefly.
|
|
433
|
+
await waitForAgentChattrReady(session.acServerPort, 10000);
|
|
434
|
+
|
|
435
|
+
// Best-effort cleanup of the stale registration on disk so the
|
|
436
|
+
// fresh register isn't shoved into a slot 2 by leftover state.
|
|
437
|
+
const stale = readPersistedAgentToken(projectId, agentId);
|
|
438
|
+
if (stale) {
|
|
439
|
+
await deregisterAgent(session.acServerPort, agentId, stale).catch(() => {});
|
|
440
|
+
clearPersistedAgentToken(projectId, agentId);
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
const replacement = await registerAgent(session.acServerPort, agentId, agentCfg.display_name || null);
|
|
444
|
+
if (!replacement) return;
|
|
445
|
+
|
|
446
|
+
const previousName = session.acRegistrationName;
|
|
447
|
+
session.acRegistrationName = replacement.name;
|
|
448
|
+
session.acRegistrationToken = replacement.token;
|
|
449
|
+
writePersistedAgentToken(projectId, agentId, replacement.token);
|
|
450
|
+
|
|
451
|
+
// Refresh whichever MCP transport this agent uses so subsequent
|
|
452
|
+
// tool calls (and the queue-watcher's `mcp read` injections) hit
|
|
453
|
+
// AC with the new bearer token instead of the now-rejected one.
|
|
454
|
+
if (session.acInjectMode === "flag" && session.acMcpHttpPort) {
|
|
455
|
+
try { writeMcpConfigFile(projectId, agentId, session.acMcpHttpPort, replacement.token); } catch {}
|
|
456
|
+
} else if (session.acInjectMode === "proxy_flag") {
|
|
457
|
+
// Codex is pinned to the original ephemeral proxy URL, so we
|
|
458
|
+
// can't tear the listener down — mutate the token in place.
|
|
459
|
+
try { updateMcpProxyToken(projectId, agentId, replacement.token); } catch {}
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
// If the assigned name changed (e.g. multi-instance slot collision)
|
|
463
|
+
// the queue watcher is now polling the wrong file. Restart it
|
|
464
|
+
// against the new name so chat reaches the right agent.
|
|
465
|
+
if (replacement.name !== previousName && session.term) {
|
|
466
|
+
if (session.queueWatcherHandle) {
|
|
467
|
+
stopQueueWatcher(session.queueWatcherHandle);
|
|
468
|
+
session.queueWatcherHandle = null;
|
|
469
|
+
}
|
|
470
|
+
try {
|
|
471
|
+
const { dir: acDir } = resolveProjectChattr(projectId);
|
|
472
|
+
if (acDir) {
|
|
473
|
+
const dataDir = path.join(acDir, "data");
|
|
474
|
+
session.queueWatcherHandle = startQueueWatcher(dataDir, replacement.name, session.term);
|
|
475
|
+
}
|
|
476
|
+
} catch {}
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
308
480
|
// Helper: spawn a PTY for a project/agent and register in agentSessions
|
|
309
481
|
async function spawnAgentPty(project, agent) {
|
|
310
482
|
const key = `${project}/${agent}`;
|
|
@@ -313,7 +485,8 @@ async function spawnAgentPty(project, agent) {
|
|
|
313
485
|
if (!cwd) return { ok: false, error: `Unknown agent: ${key}` };
|
|
314
486
|
|
|
315
487
|
const command = resolveAgentCommand(project, agent) || (process.env.SHELL || "/bin/zsh");
|
|
316
|
-
const
|
|
488
|
+
const built = await buildAgentArgs(project, agent);
|
|
489
|
+
const args = built.args;
|
|
317
490
|
const extraEnv = buildAgentEnv(project, agent);
|
|
318
491
|
|
|
319
492
|
try {
|
|
@@ -325,9 +498,62 @@ async function spawnAgentPty(project, agent) {
|
|
|
325
498
|
env: { ...process.env, ...extraEnv },
|
|
326
499
|
});
|
|
327
500
|
|
|
328
|
-
const session = {
|
|
501
|
+
const session = {
|
|
502
|
+
projectId: project,
|
|
503
|
+
agentId: agent,
|
|
504
|
+
term,
|
|
505
|
+
ws: null,
|
|
506
|
+
state: "running",
|
|
507
|
+
error: null,
|
|
508
|
+
acRegistrationName: built.acRegistrationName,
|
|
509
|
+
acServerPort: built.acServerPort,
|
|
510
|
+
acRegistrationToken: built.acRegistrationToken,
|
|
511
|
+
acInjectMode: built.acInjectMode,
|
|
512
|
+
acMcpHttpPort: built.acMcpHttpPort,
|
|
513
|
+
acHeartbeatHandle: null,
|
|
514
|
+
queueWatcherHandle: null,
|
|
515
|
+
};
|
|
329
516
|
agentSessions.set(key, session);
|
|
330
517
|
|
|
518
|
+
// #391 / quadwork#250: keep this agent alive in AgentChattr by
|
|
519
|
+
// POSTing /api/heartbeat/{name} every 5s. Without it, AC's 60s
|
|
520
|
+
// crash-detection window deregisters the agent and chat messages
|
|
521
|
+
// never reach it. Mirrors wrapper.py:_heartbeat (lines 715-748).
|
|
522
|
+
if (session.acRegistrationName && session.acServerPort && session.acRegistrationToken) {
|
|
523
|
+
// #394 / quadwork#253: pass getters (not raw values) so the 409
|
|
524
|
+
// recovery path below can swap acRegistrationName/Token in place
|
|
525
|
+
// and the very next heartbeat tick uses the replacement
|
|
526
|
+
// credentials without us having to tear down + restart the
|
|
527
|
+
// interval.
|
|
528
|
+
session.acHeartbeatHandle = startHeartbeat(
|
|
529
|
+
session.acServerPort,
|
|
530
|
+
() => session.acRegistrationName,
|
|
531
|
+
() => session.acRegistrationToken,
|
|
532
|
+
{ onConflict: () => recoverFrom409(project, agent, session) },
|
|
533
|
+
);
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
// #393 / quadwork#251: queue watcher — the actual mechanism by
|
|
537
|
+
// which agents pick up chat. Without this an agent can be
|
|
538
|
+
// registered + heartbeating yet still never respond, because
|
|
539
|
+
// AgentChattr only writes to {data_dir}/{name}_queue.jsonl and
|
|
540
|
+
// expects the agent side to poll + inject `mcp read`.
|
|
541
|
+
if (session.acRegistrationName && session.term) {
|
|
542
|
+
try {
|
|
543
|
+
const { dir: acDir } = resolveProjectChattr(project);
|
|
544
|
+
if (acDir) {
|
|
545
|
+
const dataDir = path.join(acDir, "data");
|
|
546
|
+
session.queueWatcherHandle = startQueueWatcher(
|
|
547
|
+
dataDir,
|
|
548
|
+
session.acRegistrationName,
|
|
549
|
+
session.term,
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
} catch {
|
|
553
|
+
// best-effort — failure here just means no chat injection
|
|
554
|
+
}
|
|
555
|
+
}
|
|
556
|
+
|
|
331
557
|
term.onExit(({ exitCode }) => {
|
|
332
558
|
const current = agentSessions.get(key);
|
|
333
559
|
if (current && current.term === term) {
|
|
@@ -339,6 +565,27 @@ async function spawnAgentPty(project, agent) {
|
|
|
339
565
|
current.ws.close(1000, `exited:${exitCode}`);
|
|
340
566
|
}
|
|
341
567
|
current.ws = null;
|
|
568
|
+
// #391 / quadwork#250: a crashed PTY must also clear its
|
|
569
|
+
// heartbeat interval (otherwise it leaks and a later /start
|
|
570
|
+
// double-registers) and free the AgentChattr slot (otherwise
|
|
571
|
+
// the agent stays falsely `active` forever and the next
|
|
572
|
+
// register lands at slot 2). Deregister is best-effort.
|
|
573
|
+
if (current.acHeartbeatHandle) {
|
|
574
|
+
stopHeartbeat(current.acHeartbeatHandle);
|
|
575
|
+
current.acHeartbeatHandle = null;
|
|
576
|
+
}
|
|
577
|
+
if (current.queueWatcherHandle) {
|
|
578
|
+
stopQueueWatcher(current.queueWatcherHandle);
|
|
579
|
+
current.queueWatcherHandle = null;
|
|
580
|
+
}
|
|
581
|
+
if (current.acRegistrationName && current.acServerPort) {
|
|
582
|
+
deregisterAgent(current.acServerPort, current.acRegistrationName).catch(() => {});
|
|
583
|
+
if (current.projectId && current.agentId) {
|
|
584
|
+
try { clearPersistedAgentToken(current.projectId, current.agentId); } catch {}
|
|
585
|
+
}
|
|
586
|
+
current.acRegistrationName = null;
|
|
587
|
+
current.acRegistrationToken = null;
|
|
588
|
+
}
|
|
342
589
|
}
|
|
343
590
|
});
|
|
344
591
|
|
|
@@ -349,8 +596,11 @@ async function spawnAgentPty(project, agent) {
|
|
|
349
596
|
}
|
|
350
597
|
}
|
|
351
598
|
|
|
352
|
-
// Helper: stop an agent session — kill PTY, close WS
|
|
353
|
-
|
|
599
|
+
// Helper: stop an agent session — kill PTY, close WS, deregister.
|
|
600
|
+
// Async because deregister must complete before a restart re-registers,
|
|
601
|
+
// otherwise the old slot stays occupied and a fresh register lands at
|
|
602
|
+
// head-2 instead of slot 1 (#241).
|
|
603
|
+
async function stopAgentSession(key) {
|
|
354
604
|
const session = agentSessions.get(key);
|
|
355
605
|
if (!session) {
|
|
356
606
|
agentSessions.set(key, { projectId: null, agentId: null, term: null, ws: null, state: "stopped", error: null });
|
|
@@ -366,6 +616,32 @@ function stopAgentSession(key) {
|
|
|
366
616
|
session.ws = null;
|
|
367
617
|
session.state = "stopped";
|
|
368
618
|
session.error = null;
|
|
619
|
+
// Stop heartbeat before deregister so we don't race a final POST
|
|
620
|
+
// against AgentChattr removing the name (#391 / quadwork#250).
|
|
621
|
+
if (session.acHeartbeatHandle) {
|
|
622
|
+
stopHeartbeat(session.acHeartbeatHandle);
|
|
623
|
+
session.acHeartbeatHandle = null;
|
|
624
|
+
}
|
|
625
|
+
// Stop queue watcher (#393 / quadwork#251) — the PTY is gone,
|
|
626
|
+
// injecting into a dead term would throw on the next tick.
|
|
627
|
+
if (session.queueWatcherHandle) {
|
|
628
|
+
stopQueueWatcher(session.queueWatcherHandle);
|
|
629
|
+
session.queueWatcherHandle = null;
|
|
630
|
+
}
|
|
631
|
+
// Best-effort deregister from AgentChattr (#241) so the slot frees
|
|
632
|
+
// and the next register lands at slot 1 instead of head-2.
|
|
633
|
+
if (session.acRegistrationName && session.acServerPort) {
|
|
634
|
+
try {
|
|
635
|
+
await deregisterAgent(session.acServerPort, session.acRegistrationName);
|
|
636
|
+
} catch {
|
|
637
|
+
// best-effort — failures are non-fatal
|
|
638
|
+
}
|
|
639
|
+
if (session.projectId && session.agentId) {
|
|
640
|
+
clearPersistedAgentToken(session.projectId, session.agentId);
|
|
641
|
+
}
|
|
642
|
+
session.acRegistrationName = null;
|
|
643
|
+
session.acRegistrationToken = null;
|
|
644
|
+
}
|
|
369
645
|
// Clean up MCP auth proxy if running
|
|
370
646
|
const [projectId, agentId] = key.split("/");
|
|
371
647
|
if (projectId && agentId) stopMcpProxy(projectId, agentId);
|
|
@@ -630,10 +906,10 @@ app.post("/api/agents/:project/:agent/start", async (req, res) => {
|
|
|
630
906
|
|
|
631
907
|
// --- Lifecycle: stop kills PTY + closes WS ---
|
|
632
908
|
|
|
633
|
-
app.post("/api/agents/:project/:agent/stop", (req, res) => {
|
|
909
|
+
app.post("/api/agents/:project/:agent/stop", async (req, res) => {
|
|
634
910
|
const { project, agent } = req.params;
|
|
635
911
|
const key = `${project}/${agent}`;
|
|
636
|
-
stopAgentSession(key);
|
|
912
|
+
await stopAgentSession(key);
|
|
637
913
|
res.json({ ok: true, state: "stopped" });
|
|
638
914
|
});
|
|
639
915
|
|
|
@@ -643,16 +919,16 @@ app.post("/api/agents/:project/:agent/restart", async (req, res) => {
|
|
|
643
919
|
const { project, agent } = req.params;
|
|
644
920
|
const key = `${project}/${agent}`;
|
|
645
921
|
|
|
646
|
-
|
|
922
|
+
// #241: must await deregister before respawn so the slot frees and
|
|
923
|
+
// the fresh register lands at slot 1 instead of head-2.
|
|
924
|
+
await stopAgentSession(key);
|
|
647
925
|
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
}
|
|
655
|
-
}, 500);
|
|
926
|
+
const result = await spawnAgentPty(project, agent);
|
|
927
|
+
if (result.ok) {
|
|
928
|
+
res.json({ ok: true, state: "running", pid: result.pid });
|
|
929
|
+
} else {
|
|
930
|
+
res.status(500).json({ ok: false, state: "error", error: result.error });
|
|
931
|
+
}
|
|
656
932
|
});
|
|
657
933
|
|
|
658
934
|
// --- Sessions tracking (for /api/projects dashboard) ---
|
|
@@ -0,0 +1,114 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-agent queue watcher (#393 / quadwork#251).
|
|
3
|
+
*
|
|
4
|
+
* AgentChattr does NOT push chat to agents. When the operator types
|
|
5
|
+
* `@head` in chat, AC writes a job line to `{data_dir}/{name}_queue.jsonl`
|
|
6
|
+
* and walks away. Something on the agent side has to poll that file and
|
|
7
|
+
* inject an `mcp read` prompt into the running CLI's PTY so the agent
|
|
8
|
+
* picks up the chat. Without that injection the agent never responds,
|
|
9
|
+
* even when registration and heartbeats work.
|
|
10
|
+
*
|
|
11
|
+
* Reference: /Users/cho/Projects/agentchattr/wrapper.py lines 438-541
|
|
12
|
+
* (`_queue_watcher`). Polling (not fs.watch) is intentional: matches
|
|
13
|
+
* wrapper.py's behavior and avoids the cross-platform fs.watch
|
|
14
|
+
* footguns. The role/rules/identity-hint additions from wrapper.py
|
|
15
|
+
* lines 501-528 are intentionally out of scope for v1 per the issue.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
const fs = require("fs");
|
|
19
|
+
const path = require("path");
|
|
20
|
+
|
|
21
|
+
const POLL_INTERVAL_MS = 1000;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Start polling `{dataDir}/{agentName}_queue.jsonl`. When non-empty,
|
|
25
|
+
* read all lines, truncate the file (atomic-ish claim — same race the
|
|
26
|
+
* Python wrapper accepts), parse each JSON line, build a single
|
|
27
|
+
* injected prompt, and write it into the supplied PTY terminal.
|
|
28
|
+
*
|
|
29
|
+
* Returns an opaque interval handle. Pass it to stopQueueWatcher to
|
|
30
|
+
* cancel; safe to call with null.
|
|
31
|
+
*/
|
|
32
|
+
function startQueueWatcher(dataDir, agentName, ptyTerm) {
|
|
33
|
+
if (!dataDir || !agentName || !ptyTerm) return null;
|
|
34
|
+
const queueFile = path.join(dataDir, `${agentName}_queue.jsonl`);
|
|
35
|
+
|
|
36
|
+
const tick = () => {
|
|
37
|
+
try {
|
|
38
|
+
if (!fs.existsSync(queueFile)) return;
|
|
39
|
+
const stat = fs.statSync(queueFile);
|
|
40
|
+
if (stat.size === 0) return;
|
|
41
|
+
|
|
42
|
+
const content = fs.readFileSync(queueFile, "utf-8");
|
|
43
|
+
// Atomic claim: truncate immediately so the next AC write lands
|
|
44
|
+
// in an empty file and we don't double-process the same job on
|
|
45
|
+
// the next tick. There's a small race if AC writes between the
|
|
46
|
+
// read and the truncate; wrapper.py accepts the same race.
|
|
47
|
+
fs.writeFileSync(queueFile, "");
|
|
48
|
+
|
|
49
|
+
const lines = content.split("\n").map((l) => l.trim()).filter(Boolean);
|
|
50
|
+
if (lines.length === 0) return;
|
|
51
|
+
|
|
52
|
+
let channel = "general";
|
|
53
|
+
let customPrompt = "";
|
|
54
|
+
let jobId = null;
|
|
55
|
+
let hasTrigger = false;
|
|
56
|
+
for (const line of lines) {
|
|
57
|
+
let data;
|
|
58
|
+
try {
|
|
59
|
+
data = JSON.parse(line);
|
|
60
|
+
} catch {
|
|
61
|
+
continue;
|
|
62
|
+
}
|
|
63
|
+
hasTrigger = true;
|
|
64
|
+
if (data && typeof data === "object") {
|
|
65
|
+
if (typeof data.channel === "string") channel = data.channel;
|
|
66
|
+
// AgentChattr serializes job_id as an integer (agents.py
|
|
67
|
+
// defines `job_id: int | None`), so accept both numbers and
|
|
68
|
+
// strings here. Without this, job-thread triggers fall back
|
|
69
|
+
// to the channel prompt and the agent reads the wrong
|
|
70
|
+
// conversation. Cast to string for the prompt template.
|
|
71
|
+
if (typeof data.job_id === "number" || typeof data.job_id === "string") {
|
|
72
|
+
jobId = String(data.job_id);
|
|
73
|
+
}
|
|
74
|
+
if (typeof data.prompt === "string" && data.prompt.trim()) {
|
|
75
|
+
customPrompt = data.prompt.trim();
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!hasTrigger) return;
|
|
80
|
+
|
|
81
|
+
let prompt;
|
|
82
|
+
if (customPrompt) {
|
|
83
|
+
prompt = customPrompt;
|
|
84
|
+
} else if (jobId) {
|
|
85
|
+
prompt = `mcp read job_id=${jobId} - you were mentioned in a job thread, take appropriate action`;
|
|
86
|
+
} else {
|
|
87
|
+
prompt = `mcp read #${channel} - you were mentioned, take appropriate action`;
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Flatten newlines: multi-line writes trigger paste detection in
|
|
91
|
+
// Claude Code (shows "[Pasted text +N]") and can break injection
|
|
92
|
+
// of long prompts. Mirrors wrapper.py:532.
|
|
93
|
+
const flat = prompt.replace(/\n/g, " ");
|
|
94
|
+
ptyTerm.write(flat + "\r");
|
|
95
|
+
} catch {
|
|
96
|
+
// Swallow — next tick will retry. Logging here would spam the
|
|
97
|
+
// server output once per second on a permission error.
|
|
98
|
+
}
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
return setInterval(tick, POLL_INTERVAL_MS);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Stop a watcher started by startQueueWatcher. Safe to call with null.
|
|
106
|
+
*/
|
|
107
|
+
function stopQueueWatcher(handle) {
|
|
108
|
+
if (handle) clearInterval(handle);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
module.exports = {
|
|
112
|
+
startQueueWatcher,
|
|
113
|
+
stopQueueWatcher,
|
|
114
|
+
};
|
package/server/routes.js
CHANGED
|
@@ -922,6 +922,44 @@ router.post("/api/setup", (req, res) => {
|
|
|
922
922
|
// ~/.quadwork/{id}/OVERNIGHT-QUEUE.md.
|
|
923
923
|
writeOvernightQueueFileSafe(id, name || id, repo);
|
|
924
924
|
|
|
925
|
+
// Batch 28 / #392 / quadwork#252: auto-spawn the per-project
|
|
926
|
+
// AgentChattr process. The CLI wizard's writeAgentChattrConfig
|
|
927
|
+
// does this; the web wizard previously left the install dormant
|
|
928
|
+
// until the user clicked Restart, so MCP fell through to a stale
|
|
929
|
+
// instance on port 8300. Mirror the loopback-restart pattern
|
|
930
|
+
// already used by the agentchattr-config branch above. Failures
|
|
931
|
+
// are non-fatal — the dashboard's Restart button is still
|
|
932
|
+
// available, and per the issue add-config must still return ok.
|
|
933
|
+
try {
|
|
934
|
+
const qwPort = cfg.port || 8400;
|
|
935
|
+
fetch(
|
|
936
|
+
`http://127.0.0.1:${qwPort}/api/agentchattr/${encodeURIComponent(id)}/restart`,
|
|
937
|
+
{ method: "POST" },
|
|
938
|
+
)
|
|
939
|
+
.then(async (r) => {
|
|
940
|
+
// /restart reports spawn failures (e.g. port collision —
|
|
941
|
+
// server/index.js:650-668) as HTTP 500, so a resolved
|
|
942
|
+
// fetch is not the same thing as a successful spawn. Log
|
|
943
|
+
// non-2xx responses with status and body so the operator
|
|
944
|
+
// can see why the auto-spawn silently didn't take.
|
|
945
|
+
if (!r.ok) {
|
|
946
|
+
let detail = "";
|
|
947
|
+
try { detail = (await r.text()).slice(0, 500); } catch {}
|
|
948
|
+
console.warn(
|
|
949
|
+
`[setup] auto-spawn AgentChattr for ${id} returned HTTP ${r.status}: ${detail}`,
|
|
950
|
+
);
|
|
951
|
+
}
|
|
952
|
+
})
|
|
953
|
+
.catch((err) => {
|
|
954
|
+
console.warn(
|
|
955
|
+
`[setup] auto-spawn AgentChattr for ${id} failed:`,
|
|
956
|
+
err.message || err,
|
|
957
|
+
);
|
|
958
|
+
});
|
|
959
|
+
} catch (err) {
|
|
960
|
+
console.warn(`[setup] auto-spawn AgentChattr for ${id} skipped:`, err.message || err);
|
|
961
|
+
}
|
|
962
|
+
|
|
925
963
|
return res.json({ ok: true });
|
|
926
964
|
}
|
|
927
965
|
default:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|