@agent-relay/dashboard-server 2.0.64 → 2.0.66-beta.0
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/dist/messageBuffer.d.ts +38 -0
- package/dist/messageBuffer.d.ts.map +1 -0
- package/dist/messageBuffer.js +72 -0
- package/dist/messageBuffer.js.map +1 -0
- package/dist/server.d.ts.map +1 -1
- package/dist/server.js +219 -41
- package/dist/server.js.map +1 -1
- package/out/404.html +1 -1
- package/out/_next/static/chunks/118-4c8241b0218335de.js +1 -0
- package/out/_next/static/chunks/{202-6cfbf8339f05e5ef.js → 202-fc0763dd7488e58f.js} +1 -1
- package/out/_next/static/chunks/259-3bbaad41b2550936.js +1 -0
- package/out/_next/static/chunks/285-1cb1c0ed74f31c6c.js +1 -0
- package/out/_next/static/chunks/722-85011b58b9caf88b.js +1 -0
- package/out/_next/static/chunks/994-0ce5f1d759089504.js +1 -0
- package/out/_next/static/chunks/app/app/[[...slug]]/page-589620c567f85400.js +1 -0
- package/out/_next/static/chunks/app/{page-a32b25323fff7aa0.js → page-5c60a00d938ac40a.js} +1 -1
- package/out/_next/static/chunks/app/providers/setup/[provider]/page-f058bf6696242d7b.js +1 -0
- package/out/_next/static/css/71615414d8909a44.css +1 -0
- package/out/about.html +2 -2
- package/out/about.txt +1 -1
- package/out/app/onboarding.html +1 -1
- package/out/app/onboarding.txt +1 -1
- package/out/app.html +1 -1
- package/out/app.txt +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.html +2 -2
- package/out/blog/go-to-bed-wake-up-to-a-finished-product.txt +1 -1
- package/out/blog/let-them-cook-multi-agent-orchestration.html +2 -2
- package/out/blog/let-them-cook-multi-agent-orchestration.txt +1 -1
- package/out/blog.html +2 -2
- package/out/blog.txt +1 -1
- package/out/careers.html +2 -2
- package/out/careers.txt +1 -1
- package/out/changelog.html +2 -2
- package/out/changelog.txt +1 -1
- package/out/cloud/link.html +1 -1
- package/out/cloud/link.txt +2 -2
- package/out/complete-profile.html +2 -2
- package/out/complete-profile.txt +1 -1
- package/out/connect-repos.html +1 -1
- package/out/connect-repos.txt +2 -2
- package/out/contact.html +2 -2
- package/out/contact.txt +1 -1
- package/out/docs.html +2 -2
- package/out/docs.txt +1 -1
- package/out/history.html +1 -1
- package/out/history.txt +2 -2
- package/out/index.html +1 -1
- package/out/index.txt +2 -2
- package/out/login.html +2 -2
- package/out/login.txt +2 -2
- package/out/metrics.html +1 -1
- package/out/metrics.txt +2 -2
- package/out/pricing.html +2 -2
- package/out/pricing.txt +1 -1
- package/out/privacy.html +2 -2
- package/out/privacy.txt +1 -1
- package/out/providers/setup/claude.html +1 -1
- package/out/providers/setup/claude.txt +2 -2
- package/out/providers/setup/codex.html +1 -1
- package/out/providers/setup/codex.txt +2 -2
- package/out/providers/setup/cursor.html +1 -1
- package/out/providers/setup/cursor.txt +2 -2
- package/out/providers.html +1 -1
- package/out/providers.txt +2 -2
- package/out/security.html +2 -2
- package/out/security.txt +1 -1
- package/out/signup.html +2 -2
- package/out/signup.txt +2 -2
- package/out/terms.html +2 -2
- package/out/terms.txt +1 -1
- package/package.json +1 -1
- package/out/_next/static/chunks/118-b821e49d30a9f6af.js +0 -1
- package/out/_next/static/chunks/259-b560f20df53128e5.js +0 -1
- package/out/_next/static/chunks/722-6cffbc5120f31e24.js +0 -1
- package/out/_next/static/chunks/873-9aee36b975a9556a.js +0 -1
- package/out/_next/static/chunks/994-e927457424324a78.js +0 -1
- package/out/_next/static/chunks/app/app/[[...slug]]/page-a528040db9d1fec0.js +0 -1
- package/out/_next/static/chunks/app/providers/setup/[provider]/page-c667546c4902f1b0.js +0 -1
- package/out/_next/static/css/2ee05ba949b3ac9f.css +0 -1
- /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → CRgdkwuTcA6Bt0A5Fx1wC}/_buildManifest.js +0 -0
- /package/out/_next/static/{6oI4iquYj1QbK8njLsK3s → CRgdkwuTcA6Bt0A5Fx1wC}/_ssgManifest.js +0 -0
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ring buffer for storing recent WebSocket messages.
|
|
3
|
+
* Used to replay missed messages when clients reconnect after brief disconnects.
|
|
4
|
+
*/
|
|
5
|
+
export interface BufferedMessage {
|
|
6
|
+
id: number;
|
|
7
|
+
timestamp: number;
|
|
8
|
+
type: string;
|
|
9
|
+
payload: string;
|
|
10
|
+
}
|
|
11
|
+
export declare class MessageBuffer {
|
|
12
|
+
private buffer;
|
|
13
|
+
private capacity;
|
|
14
|
+
private writeIndex;
|
|
15
|
+
private sequenceCounter;
|
|
16
|
+
constructor(capacity?: number);
|
|
17
|
+
/**
|
|
18
|
+
* Push a new message into the buffer.
|
|
19
|
+
* Returns the assigned sequence ID.
|
|
20
|
+
*/
|
|
21
|
+
push(type: string, payload: string): number;
|
|
22
|
+
/**
|
|
23
|
+
* Get all messages with an ID greater than the given sequence ID.
|
|
24
|
+
* Returns messages in chronological order.
|
|
25
|
+
*/
|
|
26
|
+
getAfter(sequenceId: number): BufferedMessage[];
|
|
27
|
+
/**
|
|
28
|
+
* Get all messages with a timestamp greater than the given timestamp.
|
|
29
|
+
* Returns messages in chronological order.
|
|
30
|
+
*/
|
|
31
|
+
getAfterTimestamp(ts: number): BufferedMessage[];
|
|
32
|
+
/**
|
|
33
|
+
* Get the current sequence ID (the ID of the last pushed message).
|
|
34
|
+
* Returns 0 if no messages have been pushed.
|
|
35
|
+
*/
|
|
36
|
+
currentId(): number;
|
|
37
|
+
}
|
|
38
|
+
//# sourceMappingURL=messageBuffer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messageBuffer.d.ts","sourceRoot":"","sources":["../src/messageBuffer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,MAAM,WAAW,eAAe;IAC9B,EAAE,EAAE,MAAM,CAAC;IACX,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,OAAO,EAAE,MAAM,CAAC;CACjB;AAED,qBAAa,aAAa;IACxB,OAAO,CAAC,MAAM,CAA6B;IAC3C,OAAO,CAAC,QAAQ,CAAS;IACzB,OAAO,CAAC,UAAU,CAAS;IAC3B,OAAO,CAAC,eAAe,CAAS;gBAEpB,QAAQ,GAAE,MAAY;IAOlC;;;OAGG;IACH,IAAI,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,GAAG,MAAM;IAa3C;;;OAGG;IACH,QAAQ,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,EAAE;IAa/C;;;OAGG;IACH,iBAAiB,CAAC,EAAE,EAAE,MAAM,GAAG,eAAe,EAAE;IAahD;;;OAGG;IACH,SAAS,IAAI,MAAM;CAGpB"}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Ring buffer for storing recent WebSocket messages.
|
|
3
|
+
* Used to replay missed messages when clients reconnect after brief disconnects.
|
|
4
|
+
*/
|
|
5
|
+
export class MessageBuffer {
|
|
6
|
+
buffer;
|
|
7
|
+
capacity;
|
|
8
|
+
writeIndex;
|
|
9
|
+
sequenceCounter;
|
|
10
|
+
constructor(capacity = 500) {
|
|
11
|
+
this.capacity = capacity;
|
|
12
|
+
this.buffer = new Array(capacity).fill(null);
|
|
13
|
+
this.writeIndex = 0;
|
|
14
|
+
this.sequenceCounter = 0;
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* Push a new message into the buffer.
|
|
18
|
+
* Returns the assigned sequence ID.
|
|
19
|
+
*/
|
|
20
|
+
push(type, payload) {
|
|
21
|
+
this.sequenceCounter++;
|
|
22
|
+
const message = {
|
|
23
|
+
id: this.sequenceCounter,
|
|
24
|
+
timestamp: Date.now(),
|
|
25
|
+
type,
|
|
26
|
+
payload,
|
|
27
|
+
};
|
|
28
|
+
this.buffer[this.writeIndex] = message;
|
|
29
|
+
this.writeIndex = (this.writeIndex + 1) % this.capacity;
|
|
30
|
+
return this.sequenceCounter;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Get all messages with an ID greater than the given sequence ID.
|
|
34
|
+
* Returns messages in chronological order.
|
|
35
|
+
*/
|
|
36
|
+
getAfter(sequenceId) {
|
|
37
|
+
const results = [];
|
|
38
|
+
for (let i = 0; i < this.capacity; i++) {
|
|
39
|
+
const msg = this.buffer[i];
|
|
40
|
+
if (msg && msg.id > sequenceId) {
|
|
41
|
+
results.push(msg);
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Sort by id to ensure chronological order
|
|
45
|
+
results.sort((a, b) => a.id - b.id);
|
|
46
|
+
return results;
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Get all messages with a timestamp greater than the given timestamp.
|
|
50
|
+
* Returns messages in chronological order.
|
|
51
|
+
*/
|
|
52
|
+
getAfterTimestamp(ts) {
|
|
53
|
+
const results = [];
|
|
54
|
+
for (let i = 0; i < this.capacity; i++) {
|
|
55
|
+
const msg = this.buffer[i];
|
|
56
|
+
if (msg && msg.timestamp > ts) {
|
|
57
|
+
results.push(msg);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// Sort by id to ensure chronological order
|
|
61
|
+
results.sort((a, b) => a.id - b.id);
|
|
62
|
+
return results;
|
|
63
|
+
}
|
|
64
|
+
/**
|
|
65
|
+
* Get the current sequence ID (the ID of the last pushed message).
|
|
66
|
+
* Returns 0 if no messages have been pushed.
|
|
67
|
+
*/
|
|
68
|
+
currentId() {
|
|
69
|
+
return this.sequenceCounter;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
//# sourceMappingURL=messageBuffer.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"messageBuffer.js","sourceRoot":"","sources":["../src/messageBuffer.ts"],"names":[],"mappings":"AAAA;;;GAGG;AASH,MAAM,OAAO,aAAa;IAChB,MAAM,CAA6B;IACnC,QAAQ,CAAS;IACjB,UAAU,CAAS;IACnB,eAAe,CAAS;IAEhC,YAAY,WAAmB,GAAG;QAChC,IAAI,CAAC,QAAQ,GAAG,QAAQ,CAAC;QACzB,IAAI,CAAC,MAAM,GAAG,IAAI,KAAK,CAAC,QAAQ,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;QAC7C,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC;QACpB,IAAI,CAAC,eAAe,GAAG,CAAC,CAAC;IAC3B,CAAC;IAED;;;OAGG;IACH,IAAI,CAAC,IAAY,EAAE,OAAe;QAChC,IAAI,CAAC,eAAe,EAAE,CAAC;QACvB,MAAM,OAAO,GAAoB;YAC/B,EAAE,EAAE,IAAI,CAAC,eAAe;YACxB,SAAS,EAAE,IAAI,CAAC,GAAG,EAAE;YACrB,IAAI;YACJ,OAAO;SACR,CAAC;QACF,IAAI,CAAC,MAAM,CAAC,IAAI,CAAC,UAAU,CAAC,GAAG,OAAO,CAAC;QACvC,IAAI,CAAC,UAAU,GAAG,CAAC,IAAI,CAAC,UAAU,GAAG,CAAC,CAAC,GAAG,IAAI,CAAC,QAAQ,CAAC;QACxD,OAAO,IAAI,CAAC,eAAe,CAAC;IAC9B,CAAC;IAED;;;OAGG;IACH,QAAQ,CAAC,UAAkB;QACzB,MAAM,OAAO,GAAsB,EAAE,CAAC;QACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,GAAG,CAAC,EAAE,GAAG,UAAU,EAAE,CAAC;gBAC/B,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QACD,2CAA2C;QAC3C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;QACpC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;OAGG;IACH,iBAAiB,CAAC,EAAU;QAC1B,MAAM,OAAO,GAAsB,EAAE,CAAC;QACtC,KAAK,IAAI,CAAC,GAAG,CAAC,EAAE,CAAC,GAAG,IAAI,CAAC,QAAQ,EAAE,CAAC,EAAE,EAAE,CAAC;YACvC,MAAM,GAAG,GAAG,IAAI,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC;YAC3B,IAAI,GAAG,IAAI,GAAG,CAAC,SAAS,GAAG,EAAE,EAAE,CAAC;gBAC9B,OAAO,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YACpB,CAAC;QACH,CAAC;QACD,2CAA2C;QAC3C,OAAO,CAAC,IAAI,CAAC,CAAC,CAAC,EAAE,CAAC,EAAE,EAAE,CAAC,CAAC,CAAC,EAAE,GAAG,CAAC,CAAC,EAAE,CAAC,CAAC;QACpC,OAAO,OAAO,CAAC;IACjB,CAAC;IAED;;;OAGG;IACH,SAAS;QACP,OAAO,IAAI,CAAC,eAAe,CAAC;IAC9B,CAAC;CACF"}
|
package/dist/server.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA8DA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;
|
|
1
|
+
{"version":3,"file":"server.d.ts","sourceRoot":"","sources":["../src/server.ts"],"names":[],"mappings":"AA8DA,OAAO,KAAK,EAAE,gBAAgB,EAAE,MAAM,kBAAkB,CAAC;AAmYzD,wBAAsB,cAAc,CAAC,IAAI,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,OAAO,EAAE,MAAM,EAAE,MAAM,CAAC,EAAE,MAAM,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC;AACvH,wBAAsB,cAAc,CAAC,OAAO,EAAE,gBAAgB,GAAG,OAAO,CAAC,MAAM,CAAC,CAAC"}
|
package/dist/server.js
CHANGED
|
@@ -5,7 +5,7 @@ import path from 'path';
|
|
|
5
5
|
import fs from 'fs';
|
|
6
6
|
import os from 'os';
|
|
7
7
|
import crypto from 'crypto';
|
|
8
|
-
import { exec } from 'child_process';
|
|
8
|
+
import { exec, execFile } from 'child_process';
|
|
9
9
|
import { fileURLToPath } from 'url';
|
|
10
10
|
import { createStorageAdapter } from '@agent-relay/storage/adapter';
|
|
11
11
|
import { RelayClient } from '@agent-relay/sdk';
|
|
@@ -55,6 +55,7 @@ function findDashboardDir() {
|
|
|
55
55
|
}
|
|
56
56
|
import { startCLIAuth, getAuthSession, cancelAuthSession, submitAuthCode, completeAuthSession, getSupportedProviders, } from '@agent-relay/daemon';
|
|
57
57
|
import { HealthWorkerManager, getHealthPort } from './services/health-worker-manager.js';
|
|
58
|
+
import { MessageBuffer } from './messageBuffer.js';
|
|
58
59
|
/**
|
|
59
60
|
* Get the host to bind to.
|
|
60
61
|
* In cloud environments, bind to '::' (IPv6 any) which also accepts IPv4 on dual-stack.
|
|
@@ -480,8 +481,11 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
480
481
|
});
|
|
481
482
|
};
|
|
482
483
|
// Initialize spawner if enabled
|
|
483
|
-
//
|
|
484
|
-
|
|
484
|
+
// When projectRoot is explicitly provided (e.g., via --project-root), use it directly.
|
|
485
|
+
// Only use detectWorkspacePath for cloud workspace auto-detection when no explicit root is given.
|
|
486
|
+
// This fixes #380: detectWorkspacePath could re-resolve projectRoot incorrectly when
|
|
487
|
+
// tool directories like ~/.nvm contain package.json markers.
|
|
488
|
+
const workspacePath = projectRoot || detectWorkspacePath(dataDir);
|
|
485
489
|
console.log(`[dashboard] Workspace path: ${workspacePath}`);
|
|
486
490
|
// When an external SpawnManager is provided (from the daemon), use it for read operations
|
|
487
491
|
// (logs, worker listing, hasWorker) and route spawn/release through the SDK client.
|
|
@@ -581,14 +585,28 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
581
585
|
// Track file watchers for externally-spawned worker logs (module scope to avoid duplicates)
|
|
582
586
|
const fileWatchers = new Map();
|
|
583
587
|
const fileLastSize = new Map();
|
|
588
|
+
// Message buffers for replay on reconnect
|
|
589
|
+
// Main buffer stores broadcast messages for the main dashboard WebSocket
|
|
590
|
+
const mainMessageBuffer = new MessageBuffer(500);
|
|
591
|
+
// Per-agent log buffers store log output for each agent (smaller capacity since per-agent)
|
|
592
|
+
const agentLogBuffers = new Map();
|
|
593
|
+
/** Get or create a log buffer for an agent */
|
|
594
|
+
const getAgentLogBuffer = (agentName) => {
|
|
595
|
+
let buffer = agentLogBuffers.get(agentName);
|
|
596
|
+
if (!buffer) {
|
|
597
|
+
buffer = new MessageBuffer(200);
|
|
598
|
+
agentLogBuffers.set(agentName, buffer);
|
|
599
|
+
}
|
|
600
|
+
return buffer;
|
|
601
|
+
};
|
|
584
602
|
// Track alive status for ping/pong keepalive on main dashboard connections
|
|
585
603
|
// This prevents TCP/proxy timeouts from killing idle workspace connections
|
|
586
604
|
const mainClientAlive = new WeakMap();
|
|
587
605
|
// Track alive status for ping/pong keepalive on bridge connections
|
|
588
606
|
const bridgeClientAlive = new WeakMap();
|
|
589
|
-
// Ping interval for main dashboard WebSocket connections (
|
|
590
|
-
//
|
|
591
|
-
const MAIN_PING_INTERVAL_MS =
|
|
607
|
+
// Ping interval for main dashboard WebSocket connections (15 seconds)
|
|
608
|
+
// Reduced from 30s to detect disconnects faster and minimize message loss window
|
|
609
|
+
const MAIN_PING_INTERVAL_MS = 15000;
|
|
592
610
|
const mainPingInterval = setInterval(() => {
|
|
593
611
|
wss.clients.forEach((ws) => {
|
|
594
612
|
if (mainClientAlive.get(ws) === false) {
|
|
@@ -602,8 +620,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
602
620
|
ws.ping();
|
|
603
621
|
});
|
|
604
622
|
}, MAIN_PING_INTERVAL_MS);
|
|
605
|
-
// Ping interval for bridge WebSocket connections (
|
|
606
|
-
|
|
623
|
+
// Ping interval for bridge WebSocket connections (15 seconds)
|
|
624
|
+
// Reduced from 30s to detect disconnects faster and minimize message loss window
|
|
625
|
+
const BRIDGE_PING_INTERVAL_MS = 15000;
|
|
607
626
|
const bridgePingInterval = setInterval(() => {
|
|
608
627
|
wssBridge.clients.forEach((ws) => {
|
|
609
628
|
if (bridgeClientAlive.get(ws) === false) {
|
|
@@ -623,6 +642,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
623
642
|
clearInterval(bridgePingInterval);
|
|
624
643
|
});
|
|
625
644
|
const onlineUsers = new Map();
|
|
645
|
+
// Track cwd per spawned agent (name -> cwd)
|
|
646
|
+
// This is set when /api/spawn is called and included in /api/spawned responses
|
|
647
|
+
const agentCwdMap = new Map();
|
|
626
648
|
// Validation helpers for presence
|
|
627
649
|
const isValidUsername = (username) => {
|
|
628
650
|
if (typeof username !== 'string')
|
|
@@ -1815,7 +1837,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
1815
1837
|
// Ignore errors reading processing state - it's optional
|
|
1816
1838
|
}
|
|
1817
1839
|
}
|
|
1818
|
-
// Mark spawned agents with isSpawned flag, team, and
|
|
1840
|
+
// Mark spawned agents with isSpawned flag, team, model, and cwd
|
|
1819
1841
|
if (spawnReader) {
|
|
1820
1842
|
const activeWorkers = spawnReader.getActiveWorkers();
|
|
1821
1843
|
for (const worker of activeWorkers) {
|
|
@@ -1825,9 +1847,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
1825
1847
|
if (worker.team) {
|
|
1826
1848
|
agent.team = worker.team;
|
|
1827
1849
|
}
|
|
1850
|
+
// Inject cwd from agentCwdMap (set during /api/spawn) or from worker info
|
|
1851
|
+
// (set by SpawnManager for relay-protocol spawns that bypass /api/spawn)
|
|
1852
|
+
const workerCwd = agentCwdMap.get(worker.name) || worker.cwd;
|
|
1853
|
+
if (workerCwd) {
|
|
1854
|
+
agent.cwd = workerCwd;
|
|
1855
|
+
}
|
|
1828
1856
|
// Extract model from spawn command (e.g., "codex --model gpt-5.2-codex" → "gpt-5.2-codex")
|
|
1829
1857
|
if (worker.cli) {
|
|
1830
|
-
|
|
1858
|
+
// Support both `--model foo` and `--model=foo`
|
|
1859
|
+
const modelMatch = worker.cli.match(/--model[=\s]+(\S+)/);
|
|
1831
1860
|
if (modelMatch) {
|
|
1832
1861
|
agent.model = modelMatch[1];
|
|
1833
1862
|
}
|
|
@@ -1852,6 +1881,21 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
1852
1881
|
// Ignore errors reading workers.json
|
|
1853
1882
|
}
|
|
1854
1883
|
}
|
|
1884
|
+
// Mark relay-protocol spawned agents (spawned by other agents, not via dashboard /api/spawn)
|
|
1885
|
+
// These agents have log files in the team directory but aren't tracked by agentCwdMap
|
|
1886
|
+
if (spawnReader) {
|
|
1887
|
+
for (const [name, agent] of agentsMap) {
|
|
1888
|
+
if (agent.isSpawned)
|
|
1889
|
+
continue;
|
|
1890
|
+
if (onlineUsers.has(name) || name === 'Dashboard')
|
|
1891
|
+
continue;
|
|
1892
|
+
// Check if there's a log file for this agent (indicates it was spawned)
|
|
1893
|
+
const logPath = path.join(teamDir, `${name}.log`);
|
|
1894
|
+
if (fs.existsSync(logPath)) {
|
|
1895
|
+
agent.isSpawned = true;
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
}
|
|
1855
1899
|
// Set team from teams.json for agents that don't have a team yet
|
|
1856
1900
|
// This ensures agents defined in teams.json are associated with their team
|
|
1857
1901
|
// even if they weren't spawned via auto-spawn
|
|
@@ -1929,12 +1973,15 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
1929
1973
|
const broadcastData = async () => {
|
|
1930
1974
|
try {
|
|
1931
1975
|
const data = await getAllData();
|
|
1932
|
-
const
|
|
1976
|
+
const rawPayload = JSON.stringify(data);
|
|
1933
1977
|
// Guard against empty/invalid payloads
|
|
1934
|
-
if (!
|
|
1978
|
+
if (!rawPayload || rawPayload.length === 0) {
|
|
1935
1979
|
console.warn('[dashboard] Skipping broadcast - empty payload');
|
|
1936
1980
|
return;
|
|
1937
1981
|
}
|
|
1982
|
+
// Push into buffer and wrap with sequence ID for replay support
|
|
1983
|
+
const seq = mainMessageBuffer.push('data', rawPayload);
|
|
1984
|
+
const payload = JSON.stringify({ seq, ...data });
|
|
1938
1985
|
wss.clients.forEach(client => {
|
|
1939
1986
|
// Skip clients that are still being initialized by the connection handler
|
|
1940
1987
|
if (initializingClients.has(client)) {
|
|
@@ -2044,6 +2091,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2044
2091
|
ws.on('pong', () => {
|
|
2045
2092
|
mainClientAlive.set(ws, true);
|
|
2046
2093
|
});
|
|
2094
|
+
// Send current sequence ID so client can track its position
|
|
2095
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2096
|
+
ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
|
|
2097
|
+
}
|
|
2047
2098
|
// Mark as initializing to prevent broadcastData from sending before we do
|
|
2048
2099
|
initializingClients.add(ws);
|
|
2049
2100
|
try {
|
|
@@ -2070,6 +2121,39 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2070
2121
|
// Now allow broadcastData to send to this client
|
|
2071
2122
|
initializingClients.delete(ws);
|
|
2072
2123
|
}
|
|
2124
|
+
// Handle messages from client (replay requests, etc.)
|
|
2125
|
+
ws.on('message', (data) => {
|
|
2126
|
+
try {
|
|
2127
|
+
const msg = JSON.parse(data.toString());
|
|
2128
|
+
// Handle replay request: client sends { type: "replay", lastSequenceId: N }
|
|
2129
|
+
if (msg.type === 'replay' && typeof msg.lastSequenceId === 'number') {
|
|
2130
|
+
const missed = mainMessageBuffer.getAfter(msg.lastSequenceId);
|
|
2131
|
+
const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
|
|
2132
|
+
console.log(`[dashboard] Client replaying ${missed.length} missed messages (gap: ${gapMs}ms)`);
|
|
2133
|
+
// Send each missed message with its original sequence ID
|
|
2134
|
+
for (const buffered of missed) {
|
|
2135
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2136
|
+
try {
|
|
2137
|
+
// Reconstruct the payload with the seq wrapper
|
|
2138
|
+
const original = JSON.parse(buffered.payload);
|
|
2139
|
+
ws.send(JSON.stringify({ seq: buffered.id, ...original }));
|
|
2140
|
+
}
|
|
2141
|
+
catch (err) {
|
|
2142
|
+
console.error('[dashboard] Failed to replay message:', err);
|
|
2143
|
+
}
|
|
2144
|
+
}
|
|
2145
|
+
}
|
|
2146
|
+
// Send current sync position after replay
|
|
2147
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2148
|
+
ws.send(JSON.stringify({ type: 'sync', sequenceId: mainMessageBuffer.currentId() }));
|
|
2149
|
+
}
|
|
2150
|
+
}
|
|
2151
|
+
}
|
|
2152
|
+
catch (err) {
|
|
2153
|
+
// Non-JSON messages are ignored (binary, etc.)
|
|
2154
|
+
debug(`[dashboard] Unhandled main WebSocket message: ${err}`);
|
|
2155
|
+
}
|
|
2156
|
+
});
|
|
2073
2157
|
ws.on('error', (err) => {
|
|
2074
2158
|
console.error('[dashboard] WebSocket client error:', err);
|
|
2075
2159
|
});
|
|
@@ -2105,9 +2189,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2105
2189
|
});
|
|
2106
2190
|
// Track alive status for ping/pong keepalive on log connections
|
|
2107
2191
|
const logClientAlive = new WeakMap();
|
|
2108
|
-
// Ping interval for log WebSocket connections (
|
|
2109
|
-
//
|
|
2110
|
-
const LOG_PING_INTERVAL_MS =
|
|
2192
|
+
// Ping interval for log WebSocket connections (15 seconds)
|
|
2193
|
+
// Reduced from 30s to detect disconnects faster and minimize message loss window
|
|
2194
|
+
const LOG_PING_INTERVAL_MS = 15000;
|
|
2111
2195
|
const logPingInterval = setInterval(() => {
|
|
2112
2196
|
wssLogs.clients.forEach((ws) => {
|
|
2113
2197
|
if (logClientAlive.get(ws) === false) {
|
|
@@ -2135,6 +2219,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2135
2219
|
ws.on('pong', () => {
|
|
2136
2220
|
logClientAlive.set(ws, true);
|
|
2137
2221
|
});
|
|
2222
|
+
// Send sync message with current server timestamp so client can track its position
|
|
2223
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2224
|
+
ws.send(JSON.stringify({ type: 'sync', serverTimestamp: Date.now() }));
|
|
2225
|
+
}
|
|
2138
2226
|
// Helper to check if agent is daemon-connected (from agents.json)
|
|
2139
2227
|
const isDaemonConnected = (agentName) => {
|
|
2140
2228
|
const agentsPath = path.join(teamDir, 'agents.json');
|
|
@@ -2214,6 +2302,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2214
2302
|
data: newContent,
|
|
2215
2303
|
timestamp: new Date().toISOString(),
|
|
2216
2304
|
});
|
|
2305
|
+
// Push into per-agent log buffer for replay on reconnect
|
|
2306
|
+
getAgentLogBuffer(agentName).push('output', payload);
|
|
2217
2307
|
for (const client of clients) {
|
|
2218
2308
|
if (client.readyState === WebSocket.OPEN) {
|
|
2219
2309
|
client.send(payload);
|
|
@@ -2388,6 +2478,29 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2388
2478
|
}));
|
|
2389
2479
|
}
|
|
2390
2480
|
}
|
|
2481
|
+
// Handle replay request: client sends { type: "replay", agent: "name", lastTimestamp: N }
|
|
2482
|
+
// Logs use timestamps instead of sequence IDs since the data is raw text
|
|
2483
|
+
if (msg.type === 'replay' && typeof msg.agent === 'string' && typeof msg.lastTimestamp === 'number') {
|
|
2484
|
+
const logBuffer = agentLogBuffers.get(msg.agent);
|
|
2485
|
+
if (logBuffer) {
|
|
2486
|
+
const missed = logBuffer.getAfterTimestamp(msg.lastTimestamp);
|
|
2487
|
+
const gapMs = missed.length > 0 ? Date.now() - missed[0].timestamp : 0;
|
|
2488
|
+
console.log(`[dashboard] Client replaying ${missed.length} missed log messages for ${msg.agent} (gap: ${gapMs}ms)`);
|
|
2489
|
+
// Send replay as a structured response the client expects
|
|
2490
|
+
if (ws.readyState === WebSocket.OPEN) {
|
|
2491
|
+
try {
|
|
2492
|
+
const entries = missed.map(m => ({
|
|
2493
|
+
content: m.payload,
|
|
2494
|
+
timestamp: m.timestamp,
|
|
2495
|
+
}));
|
|
2496
|
+
ws.send(JSON.stringify({ type: 'replay', entries }));
|
|
2497
|
+
}
|
|
2498
|
+
catch (err) {
|
|
2499
|
+
console.error('[dashboard] Failed to replay log messages:', err);
|
|
2500
|
+
}
|
|
2501
|
+
}
|
|
2502
|
+
}
|
|
2503
|
+
}
|
|
2391
2504
|
}
|
|
2392
2505
|
catch (err) {
|
|
2393
2506
|
console.error('[dashboard] Invalid logs WebSocket message:', err);
|
|
@@ -2408,6 +2521,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2408
2521
|
watcher.close();
|
|
2409
2522
|
fileWatchers.delete(agentName);
|
|
2410
2523
|
fileLastSize.delete(agentName);
|
|
2524
|
+
agentLogBuffers.delete(agentName);
|
|
2411
2525
|
console.log(`[dashboard] Stopped watching log file for: ${agentName}`);
|
|
2412
2526
|
}
|
|
2413
2527
|
}
|
|
@@ -2461,12 +2575,16 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2461
2575
|
agentHashes.delete(oldest);
|
|
2462
2576
|
}
|
|
2463
2577
|
}
|
|
2464
|
-
const
|
|
2578
|
+
const logPayload = {
|
|
2465
2579
|
type: 'output',
|
|
2466
2580
|
agent: agentName,
|
|
2467
2581
|
data: output,
|
|
2468
2582
|
timestamp: new Date().toISOString(),
|
|
2469
|
-
}
|
|
2583
|
+
};
|
|
2584
|
+
const payload = JSON.stringify(logPayload);
|
|
2585
|
+
// Push into per-agent log buffer for replay on reconnect
|
|
2586
|
+
// Logs use timestamps instead of sequence IDs since the data is raw text
|
|
2587
|
+
getAgentLogBuffer(agentName).push('output', payload);
|
|
2470
2588
|
for (const client of clients) {
|
|
2471
2589
|
if (client.readyState === WebSocket.OPEN) {
|
|
2472
2590
|
client.send(payload);
|
|
@@ -2488,7 +2606,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2488
2606
|
// Helper to broadcast channel messages to all connected clients
|
|
2489
2607
|
// Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
|
|
2490
2608
|
const broadcastChannelMessage = (message) => {
|
|
2491
|
-
|
|
2609
|
+
// Push into buffer and wrap with sequence ID for replay support
|
|
2610
|
+
const rawPayload = JSON.stringify(message);
|
|
2611
|
+
const seq = mainMessageBuffer.push('channel_message', rawPayload);
|
|
2612
|
+
const payload = JSON.stringify({ seq, ...message });
|
|
2492
2613
|
// Broadcast to main WebSocket clients (local mode)
|
|
2493
2614
|
wss.clients.forEach((client) => {
|
|
2494
2615
|
if (client.readyState === WebSocket.OPEN) {
|
|
@@ -2506,7 +2627,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
2506
2627
|
// This enables agent replies to appear in the dashboard UI
|
|
2507
2628
|
// Broadcasts to both main wss (local mode) and wssPresence (cloud mode)
|
|
2508
2629
|
const broadcastDirectMessage = (message) => {
|
|
2509
|
-
|
|
2630
|
+
// Push into buffer and wrap with sequence ID for replay support
|
|
2631
|
+
const rawPayload = JSON.stringify(message);
|
|
2632
|
+
const seq = mainMessageBuffer.push('direct_message', rawPayload);
|
|
2633
|
+
const payload = JSON.stringify({ seq, ...message });
|
|
2510
2634
|
// Broadcast to main WebSocket clients (local mode)
|
|
2511
2635
|
const mainClients = Array.from(wss.clients).filter(c => c.readyState === WebSocket.OPEN);
|
|
2512
2636
|
debug(`[dashboard] Broadcasting direct_message to ${mainClients.length} main clients`);
|
|
@@ -4067,26 +4191,9 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4067
4191
|
});
|
|
4068
4192
|
}
|
|
4069
4193
|
}
|
|
4070
|
-
//
|
|
4071
|
-
|
|
4072
|
-
|
|
4073
|
-
const data = JSON.parse(fs.readFileSync(agentsPath, 'utf-8'));
|
|
4074
|
-
const registeredAgents = data.agents || [];
|
|
4075
|
-
for (const agent of registeredAgents) {
|
|
4076
|
-
if (!agents.find(a => a.name === agent.name)) {
|
|
4077
|
-
// Check if recently active (within 30 seconds)
|
|
4078
|
-
const lastSeen = agent.lastSeen ? new Date(agent.lastSeen).getTime() : 0;
|
|
4079
|
-
const isActive = Date.now() - lastSeen < 30000;
|
|
4080
|
-
if (isActive) {
|
|
4081
|
-
agents.push({
|
|
4082
|
-
name: agent.name,
|
|
4083
|
-
status: 'active',
|
|
4084
|
-
alertLevel: 'normal',
|
|
4085
|
-
});
|
|
4086
|
-
}
|
|
4087
|
-
}
|
|
4088
|
-
}
|
|
4089
|
-
}
|
|
4194
|
+
// Note: We only show spawned agents with actual PIDs in memory metrics.
|
|
4195
|
+
// Human users and non-process entries from agents.json are excluded since
|
|
4196
|
+
// they don't have memory usage to track.
|
|
4090
4197
|
res.json({
|
|
4091
4198
|
agents,
|
|
4092
4199
|
system: {
|
|
@@ -4564,6 +4671,20 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4564
4671
|
const online = isAgentOnline(name);
|
|
4565
4672
|
res.json({ name, online });
|
|
4566
4673
|
});
|
|
4674
|
+
/**
|
|
4675
|
+
* PUT /api/agents/:name/cwd - Register an agent's working directory
|
|
4676
|
+
* Used by relay-pty-orchestrator after daemon socket spawns (which bypass /api/spawn).
|
|
4677
|
+
*/
|
|
4678
|
+
app.put('/api/agents/:name/cwd', (req, res) => {
|
|
4679
|
+
const { name } = req.params;
|
|
4680
|
+
const { cwd } = req.body || {};
|
|
4681
|
+
if (!cwd || typeof cwd !== 'string') {
|
|
4682
|
+
return res.status(400).json({ error: 'Missing required field: cwd' });
|
|
4683
|
+
}
|
|
4684
|
+
agentCwdMap.set(name, cwd);
|
|
4685
|
+
broadcastData().catch(() => { });
|
|
4686
|
+
res.json({ success: true, name, cwd });
|
|
4687
|
+
});
|
|
4567
4688
|
// ===== Agent Spawn API =====
|
|
4568
4689
|
/**
|
|
4569
4690
|
* POST /api/spawn - Spawn a new agent
|
|
@@ -4583,6 +4704,8 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4583
4704
|
error: 'Missing required field: name',
|
|
4584
4705
|
});
|
|
4585
4706
|
}
|
|
4707
|
+
// Inherit spawner's cwd if no explicit cwd provided (for nested/agent-to-agent spawns)
|
|
4708
|
+
const effectiveCwd = cwd || (spawnerName ? agentCwdMap.get(spawnerName) : undefined);
|
|
4586
4709
|
try {
|
|
4587
4710
|
let result;
|
|
4588
4711
|
if (useExternalSpawnManager) {
|
|
@@ -4602,7 +4725,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4602
4725
|
cli,
|
|
4603
4726
|
task,
|
|
4604
4727
|
team: team || undefined,
|
|
4605
|
-
cwd:
|
|
4728
|
+
cwd: effectiveCwd || undefined,
|
|
4606
4729
|
interactive,
|
|
4607
4730
|
shadowMode,
|
|
4608
4731
|
shadowAgent,
|
|
@@ -4622,7 +4745,7 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4622
4745
|
task,
|
|
4623
4746
|
team: team || undefined,
|
|
4624
4747
|
spawnerName: spawnerName || undefined,
|
|
4625
|
-
cwd:
|
|
4748
|
+
cwd: effectiveCwd || undefined,
|
|
4626
4749
|
interactive,
|
|
4627
4750
|
shadowMode,
|
|
4628
4751
|
shadowAgent,
|
|
@@ -4635,6 +4758,10 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4635
4758
|
result = await spawner.spawn(request);
|
|
4636
4759
|
}
|
|
4637
4760
|
if (result.success) {
|
|
4761
|
+
// Track cwd for this agent so /api/spawned can return it
|
|
4762
|
+
if (effectiveCwd) {
|
|
4763
|
+
agentCwdMap.set(name, effectiveCwd);
|
|
4764
|
+
}
|
|
4638
4765
|
// Broadcast update to WebSocket clients
|
|
4639
4766
|
broadcastData().catch(() => { });
|
|
4640
4767
|
// Broadcast agent_spawned event to activity feed
|
|
@@ -4658,6 +4785,55 @@ export async function startDashboard(portOrOptions, dataDirArg, teamDirArg, dbPa
|
|
|
4658
4785
|
});
|
|
4659
4786
|
}
|
|
4660
4787
|
});
|
|
4788
|
+
/**
|
|
4789
|
+
* POST /api/repos/clone - Clone a repo into the workspace directory
|
|
4790
|
+
* Body: { fullName: "Owner/RepoName" }
|
|
4791
|
+
* Used by cloud API to hot-clone repos added to a running workspace.
|
|
4792
|
+
*/
|
|
4793
|
+
app.post('/api/repos/clone', async (req, res) => {
|
|
4794
|
+
const { fullName } = req.body;
|
|
4795
|
+
if (!fullName || typeof fullName !== 'string' || !fullName.includes('/')) {
|
|
4796
|
+
return res.status(400).json({ success: false, error: 'fullName is required (e.g., "Owner/RepoName")' });
|
|
4797
|
+
}
|
|
4798
|
+
// Validate format: "Owner/RepoName" with safe characters only
|
|
4799
|
+
if (!/^[a-zA-Z0-9._-]+\/[a-zA-Z0-9._-]+$/.test(fullName)) {
|
|
4800
|
+
return res.status(400).json({ success: false, error: 'Invalid repository name format' });
|
|
4801
|
+
}
|
|
4802
|
+
const repoName = fullName.split('/').pop();
|
|
4803
|
+
const workspaceDir = process.env.WORKSPACE_DIR || path.dirname(projectRoot || dataDir);
|
|
4804
|
+
const targetDir = path.join(workspaceDir, repoName);
|
|
4805
|
+
// Idempotent: skip if already cloned
|
|
4806
|
+
if (fs.existsSync(targetDir)) {
|
|
4807
|
+
return res.json({ success: true, message: 'Already cloned', path: targetDir });
|
|
4808
|
+
}
|
|
4809
|
+
const githubToken = process.env.GITHUB_TOKEN;
|
|
4810
|
+
if (!githubToken) {
|
|
4811
|
+
return res.status(500).json({ success: false, error: 'GITHUB_TOKEN not available' });
|
|
4812
|
+
}
|
|
4813
|
+
const cloneUrl = `https://x-access-token:${githubToken}@github.com/${fullName}.git`;
|
|
4814
|
+
try {
|
|
4815
|
+
// Use execFile to avoid shell injection
|
|
4816
|
+
await new Promise((resolve, reject) => {
|
|
4817
|
+
execFile('git', ['clone', cloneUrl, targetDir], { timeout: 120000 }, (error, _stdout, stderr) => {
|
|
4818
|
+
if (error) {
|
|
4819
|
+
reject(new Error(stderr || error.message));
|
|
4820
|
+
}
|
|
4821
|
+
else {
|
|
4822
|
+
resolve();
|
|
4823
|
+
}
|
|
4824
|
+
});
|
|
4825
|
+
});
|
|
4826
|
+
// Mark directory as safe for git
|
|
4827
|
+
execFile('git', ['config', '--global', '--add', 'safe.directory', targetDir], () => { });
|
|
4828
|
+
res.json({ success: true, path: targetDir });
|
|
4829
|
+
}
|
|
4830
|
+
catch (err) {
|
|
4831
|
+
// Sanitize error message to avoid leaking GITHUB_TOKEN embedded in the clone URL
|
|
4832
|
+
const safeMessage = (err.message || 'Clone failed').replace(/https:\/\/[^@]+@/g, 'https://***@');
|
|
4833
|
+
console.error('[api/repos/clone] Clone failed:', safeMessage);
|
|
4834
|
+
res.status(500).json({ success: false, error: safeMessage });
|
|
4835
|
+
}
|
|
4836
|
+
});
|
|
4661
4837
|
/**
|
|
4662
4838
|
* POST /api/spawn/architect - Spawn an Architect agent for bridge mode
|
|
4663
4839
|
* Body: { cli?: string }
|
|
@@ -4808,6 +4984,7 @@ Start by greeting the project leads and asking for status updates.`;
|
|
|
4808
4984
|
spawnedAt: worker.spawnedAt,
|
|
4809
4985
|
task: worker.task,
|
|
4810
4986
|
team: worker.team,
|
|
4987
|
+
cwd: agentCwdMap.get(worker.name) || worker.cwd,
|
|
4811
4988
|
source: 'spawner',
|
|
4812
4989
|
});
|
|
4813
4990
|
}
|
|
@@ -4878,6 +5055,7 @@ Start by greeting the project leads and asking for status updates.`;
|
|
|
4878
5055
|
released = await spawner.release(name);
|
|
4879
5056
|
}
|
|
4880
5057
|
if (released) {
|
|
5058
|
+
agentCwdMap.delete(name);
|
|
4881
5059
|
broadcastData().catch(() => { });
|
|
4882
5060
|
// Broadcast agent_released event to activity feed
|
|
4883
5061
|
broadcastPresence({
|