tide-commander 1.65.0 → 1.66.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/assets/{BossLogsModal-U4mcLIP3.js → BossLogsModal-D6nnEtFf.js} +1 -1
- package/dist/assets/{BossSpawnModal-CiFvgaQ1.js → BossSpawnModal-DmPw2xym.js} +1 -1
- package/dist/assets/{ControlsModal-Bz4EQ0ii.js → ControlsModal-OEZddGmt.js} +1 -1
- package/dist/assets/{DockerLogsModal-CSMh4uRZ.js → DockerLogsModal-CzpJKdxd.js} +1 -1
- package/dist/assets/{EmbeddedEditor-ecjZKKkD.js → EmbeddedEditor-Dh9neVWW.js} +1 -1
- package/dist/assets/{GmailOAuthSetup-pFwSGVxa.js → GmailOAuthSetup-FtYHQDDw.js} +1 -1
- package/dist/assets/{GoogleOAuthSetup-CPvUA9JE.js → GoogleOAuthSetup-reYSDcFV.js} +1 -1
- package/dist/assets/{IframeModal-DcTPyjLy.js → IframeModal-DbW9WCx6.js} +1 -1
- package/dist/assets/{IntegrationsPanel-DHdp8HO-.js → IntegrationsPanel-DooKtzpO.js} +2 -2
- package/dist/assets/{LogViewerModal-QWGgGmiP.js → LogViewerModal-BamqTQzc.js} +1 -1
- package/dist/assets/{MonitoringModal-C8cfJlg5.js → MonitoringModal-DiOT_xpJ.js} +1 -1
- package/dist/assets/{PM2LogsModal-uUu_bTeC.js → PM2LogsModal-C4hIfwGk.js} +1 -1
- package/dist/assets/{RestoreArchivedAreaModal-C-Zxn6n6.js → RestoreArchivedAreaModal-CQ3NdSvn.js} +1 -1
- package/dist/assets/{Scene2DCanvas-0uRu2G4V.js → Scene2DCanvas-BfcwE6aA.js} +1 -1
- package/dist/assets/{SceneManager-xyitf6BI.js → SceneManager-D409BuL6.js} +1 -1
- package/dist/assets/{SkillsPanel-Bgcx1OI3.js → SkillsPanel-B0V-BFfO.js} +1 -1
- package/dist/assets/{SpawnModal-CS3BMuY9.js → SpawnModal-gPT83rW4.js} +1 -1
- package/dist/assets/{SubordinateAssignmentModal-DNWrbv9p.js → SubordinateAssignmentModal-Dvvqv0yZ.js} +1 -1
- package/dist/assets/{TriggerManagerPanel-D-pLDqne.js → TriggerManagerPanel-BmDBe8xx.js} +1 -1
- package/dist/assets/{WorkflowEditorPanel-Cu7zjFdE.js → WorkflowEditorPanel-PS4W8Q3F.js} +1 -1
- package/dist/assets/{index-cGfzMto2.js → index-BSdgxlrR.js} +1 -1
- package/dist/assets/{index-2BVW4z0_.js → index-BkpkUL3C.js} +1 -1
- package/dist/assets/{index-CZl-6UH7.js → index-C7cIg4BE.js} +1 -1
- package/dist/assets/{index-DEfCPZTr.js → index-CJYuOBJD.js} +3 -3
- package/dist/assets/{index-Yy2LrlI7.js → index-D9NmRir8.js} +2 -2
- package/dist/assets/{index-Dd0cfrn9.js → index-DvbZsLxj.js} +1 -1
- package/dist/assets/index-baPDjRvq.js +1 -0
- package/dist/assets/{index-DM70jqOd.js → index-cCrsvvfk.js} +1 -1
- package/dist/assets/main-B3L4mgQ4.css +1 -0
- package/dist/assets/{main-BNhrjJHj.js → main-BzSUj-VM.js} +5 -5
- package/dist/assets/{web-BnX_BsjB.js → web-DffxvD3t.js} +1 -1
- package/dist/assets/{web-CyBgxhK2.js → web-y3e1lmyW.js} +1 -1
- package/dist/index.html +2 -2
- package/dist/src/packages/server/data/builtin-skills/backup-restore.js +242 -0
- package/dist/src/packages/server/data/builtin-skills/index.js +2 -0
- package/dist/src/packages/server/index.js +4 -0
- package/dist/src/packages/server/routes/agents.js +27 -0
- package/dist/src/packages/server/services/backup-service.js +148 -0
- package/package.json +2 -1
- package/scripts/backup-data.sh +116 -0
- package/scripts/krunner/install-krunner-integration.sh +53 -0
- package/scripts/krunner/org.riven.tide.krunner.service +3 -0
- package/scripts/krunner/plasma-runner-tide-commander.desktop +16 -0
- package/scripts/krunner/tide-krunner-runner.js +335 -0
- package/scripts/recover-agents.ts +623 -0
- package/dist/assets/index-CWyxpmuL.js +0 -1
- package/dist/assets/main-4iQEaD98.css +0 -1
|
@@ -0,0 +1,623 @@
|
|
|
1
|
+
#!/usr/bin/env tsx
|
|
2
|
+
/**
|
|
3
|
+
* recover-agents — rebuild ~/.local/share/tide-commander/agents.json from logs.
|
|
4
|
+
*
|
|
5
|
+
* When the persisted agent registry gets truncated or corrupted (e.g. server
|
|
6
|
+
* crash mid-write, accidental in-memory state with only a few agents written
|
|
7
|
+
* back, manual file mishap) this script reconstructs as many agents as it can
|
|
8
|
+
* from durable side-channel data:
|
|
9
|
+
*
|
|
10
|
+
* - delegation-history.json boss IDs + boss/subordinate edges
|
|
11
|
+
* - session-history.json sessionId per agent over time
|
|
12
|
+
* - running-processes.json[.bak] live agents w/ full prompt + customAgent
|
|
13
|
+
* - custom-agent-classes.json class registry (informational)
|
|
14
|
+
* - tmux ls (sessions tc-<id>) liveness signal
|
|
15
|
+
* - ~/.claude/projects/<dir>/<sessionId>.jsonl
|
|
16
|
+
* Claude Code session transcripts. We grep API response payloads for
|
|
17
|
+
* (id, name, class) tuples that the running agents reported via curl.
|
|
18
|
+
*
|
|
19
|
+
* Usage:
|
|
20
|
+
* tsx scripts/recover-agents.ts # dry run, write agents.json.recovered
|
|
21
|
+
* tsx scripts/recover-agents.ts --apply # also stop server, swap files, restart
|
|
22
|
+
* tsx scripts/recover-agents.ts --output FILE # write to a custom path
|
|
23
|
+
* tsx scripts/recover-agents.ts --data-dir DIR # use a custom data dir
|
|
24
|
+
*
|
|
25
|
+
* --apply requires the server is being run via `bun run dev` (i.e. tsx watch),
|
|
26
|
+
* because it triggers a restart by touching src/packages/server/index.ts.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import * as fs from 'fs';
|
|
30
|
+
import * as path from 'path';
|
|
31
|
+
import * as os from 'os';
|
|
32
|
+
import { fileURLToPath } from 'url';
|
|
33
|
+
import { execSync, spawnSync } from 'child_process';
|
|
34
|
+
|
|
35
|
+
const __filename = fileURLToPath(import.meta.url);
|
|
36
|
+
const __dirname = path.dirname(__filename);
|
|
37
|
+
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// Types (mirror src/packages/server/data/index.ts StoredAgent)
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
interface StoredAgent {
|
|
43
|
+
id: string;
|
|
44
|
+
name: string;
|
|
45
|
+
class: string;
|
|
46
|
+
provider?: 'claude' | 'codex' | 'opencode';
|
|
47
|
+
position: { x: number; y: number; z: number };
|
|
48
|
+
cwd: string;
|
|
49
|
+
tokensUsed: number;
|
|
50
|
+
contextUsed?: number;
|
|
51
|
+
contextLimit?: number;
|
|
52
|
+
taskCount?: number;
|
|
53
|
+
permissionMode?: string;
|
|
54
|
+
useChrome?: boolean;
|
|
55
|
+
model?: string;
|
|
56
|
+
createdAt: number;
|
|
57
|
+
lastActivity: number;
|
|
58
|
+
sessionId?: string;
|
|
59
|
+
isBoss?: boolean;
|
|
60
|
+
subordinateIds?: string[];
|
|
61
|
+
bossId?: string;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
// CLI
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
|
|
68
|
+
const args = process.argv.slice(2);
|
|
69
|
+
const flag = (k: string) => args.includes(k);
|
|
70
|
+
const argVal = (k: string): string | undefined => {
|
|
71
|
+
const i = args.indexOf(k);
|
|
72
|
+
return i >= 0 ? args[i + 1] : undefined;
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
const APPLY = flag('--apply');
|
|
76
|
+
const DATA_DIR = argVal('--data-dir') || path.join(os.homedir(), '.local/share/tide-commander');
|
|
77
|
+
const OUTPUT = argVal('--output') || path.join(DATA_DIR, 'agents.json.recovered');
|
|
78
|
+
const PROJECT_ROOT = path.resolve(__dirname, '..');
|
|
79
|
+
const SERVER_FILE = path.join(PROJECT_ROOT, 'src/packages/server/index.ts');
|
|
80
|
+
const SERVER_URL = process.env.TC_URL || 'http://localhost:5174';
|
|
81
|
+
const AUTH_TOKEN = process.env.AUTH_TOKEN || 'abcd';
|
|
82
|
+
|
|
83
|
+
// ---------------------------------------------------------------------------
|
|
84
|
+
// Helpers
|
|
85
|
+
// ---------------------------------------------------------------------------
|
|
86
|
+
|
|
87
|
+
const readJson = <T,>(p: string): T | null => {
|
|
88
|
+
try { return JSON.parse(fs.readFileSync(p, 'utf-8')); } catch { return null; }
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const log = (msg: string) => console.log(msg);
|
|
92
|
+
const warn = (msg: string) => console.warn(`! ${msg}`);
|
|
93
|
+
|
|
94
|
+
// Pokemon agent ID format: 8 lowercase alphanumerics
|
|
95
|
+
const AGENT_ID_RE = /^[a-z0-9]{8}$/;
|
|
96
|
+
|
|
97
|
+
// ---------------------------------------------------------------------------
|
|
98
|
+
// Source 1: agent metadata (id → name, class) from JSONL transcripts
|
|
99
|
+
// ---------------------------------------------------------------------------
|
|
100
|
+
|
|
101
|
+
interface AgentMeta {
|
|
102
|
+
name: string;
|
|
103
|
+
class: string;
|
|
104
|
+
mtime: number; // unix seconds
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Walk ~/.claude/projects/*\/*.jsonl and grep for the API response payload
|
|
109
|
+
* pattern `"id":"<8>","name":"X","class":"Y"`. These are the responses the
|
|
110
|
+
* running agents got back when they called `PATCH /api/agents/<id>` to set
|
|
111
|
+
* their task label etc. — so they're authoritative for (name, class).
|
|
112
|
+
*/
|
|
113
|
+
function extractAgentMetaFromTranscripts(): Map<string, AgentMeta> {
|
|
114
|
+
const meta = new Map<string, AgentMeta>();
|
|
115
|
+
const projectsDir = path.join(os.homedir(), '.claude/projects');
|
|
116
|
+
if (!fs.existsSync(projectsDir)) {
|
|
117
|
+
warn('~/.claude/projects not found — skipping JSONL transcript scan');
|
|
118
|
+
return meta;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Embedded JSON pattern (escaped quotes, since it lives inside a JSON string)
|
|
122
|
+
const pattern = /\\"id\\":\\"([a-z0-9]{8})\\",\\"name\\":\\"([^"\\]+)\\",\\"class\\":\\"([a-z0-9-]+)\\"/g;
|
|
123
|
+
|
|
124
|
+
let scanned = 0;
|
|
125
|
+
for (const project of fs.readdirSync(projectsDir)) {
|
|
126
|
+
const projDir = path.join(projectsDir, project);
|
|
127
|
+
let entries: string[];
|
|
128
|
+
try { entries = fs.readdirSync(projDir); } catch { continue; }
|
|
129
|
+
for (const file of entries) {
|
|
130
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
131
|
+
const fp = path.join(projDir, file);
|
|
132
|
+
let stat: fs.Stats;
|
|
133
|
+
try { stat = fs.statSync(fp); } catch { continue; }
|
|
134
|
+
const mtime = Math.floor(stat.mtimeMs / 1000);
|
|
135
|
+
let content: string;
|
|
136
|
+
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
137
|
+
scanned++;
|
|
138
|
+
let m: RegExpExecArray | null;
|
|
139
|
+
pattern.lastIndex = 0;
|
|
140
|
+
while ((m = pattern.exec(content))) {
|
|
141
|
+
const [, id, name, cls] = m;
|
|
142
|
+
const existing = meta.get(id);
|
|
143
|
+
if (!existing || existing.mtime < mtime) {
|
|
144
|
+
meta.set(id, { name, class: cls, mtime });
|
|
145
|
+
}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
log(` scanned ${scanned} transcript file(s) → ${meta.size} agent(s) with name/class`);
|
|
150
|
+
return meta;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
// ---------------------------------------------------------------------------
|
|
154
|
+
// Source 2: sessionId & cwd per agent (from JSONL transcripts)
|
|
155
|
+
// ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
interface AgentSession {
|
|
158
|
+
sessionId: string;
|
|
159
|
+
cwd: string;
|
|
160
|
+
mtime: number;
|
|
161
|
+
encodedDir: string;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function extractAgentSessionsFromTranscripts(): Map<string, AgentSession> {
|
|
165
|
+
// Map agentId → most-recent session
|
|
166
|
+
const sessions = new Map<string, AgentSession>();
|
|
167
|
+
const projectsDir = path.join(os.homedir(), '.claude/projects');
|
|
168
|
+
if (!fs.existsSync(projectsDir)) return sessions;
|
|
169
|
+
|
|
170
|
+
// Find each agent's session by looking for `tc-<agentId>` references in
|
|
171
|
+
// the transcript (e.g. spawn commands, log paths) OR by the agent ID being
|
|
172
|
+
// mentioned in customAgent.definition.id contexts.
|
|
173
|
+
const idMention = /\b(?:tc-|prompt-|tc-agent-|tc-initial-)([a-z0-9]{8})\b/g;
|
|
174
|
+
// also: prompt path `/home/riven/.tide-commander/prompts/prompt-<id>-project.md`
|
|
175
|
+
|
|
176
|
+
for (const project of fs.readdirSync(projectsDir)) {
|
|
177
|
+
const projDir = path.join(projectsDir, project);
|
|
178
|
+
let entries: string[];
|
|
179
|
+
try { entries = fs.readdirSync(projDir); } catch { continue; }
|
|
180
|
+
for (const file of entries) {
|
|
181
|
+
if (!file.endsWith('.jsonl')) continue;
|
|
182
|
+
const sessId = file.slice(0, -6); // strip .jsonl
|
|
183
|
+
const fp = path.join(projDir, file);
|
|
184
|
+
let stat: fs.Stats; try { stat = fs.statSync(fp); } catch { continue; }
|
|
185
|
+
const mtime = Math.floor(stat.mtimeMs / 1000);
|
|
186
|
+
// Pull cwd field from first ~10 lines (it's set per-message but stable)
|
|
187
|
+
let cwd = '';
|
|
188
|
+
try {
|
|
189
|
+
const head = fs.readFileSync(fp, 'utf-8').split('\n', 50);
|
|
190
|
+
for (const line of head) {
|
|
191
|
+
if (!line) continue;
|
|
192
|
+
try {
|
|
193
|
+
const obj = JSON.parse(line);
|
|
194
|
+
if (typeof obj.cwd === 'string') { cwd = obj.cwd; break; }
|
|
195
|
+
} catch { /* skip */ }
|
|
196
|
+
}
|
|
197
|
+
} catch { continue; }
|
|
198
|
+
|
|
199
|
+
// Find which agent ID(s) this transcript belongs to
|
|
200
|
+
let content: string;
|
|
201
|
+
try { content = fs.readFileSync(fp, 'utf-8'); } catch { continue; }
|
|
202
|
+
const ids = new Set<string>();
|
|
203
|
+
let m: RegExpExecArray | null;
|
|
204
|
+
idMention.lastIndex = 0;
|
|
205
|
+
while ((m = idMention.exec(content))) ids.add(m[1]);
|
|
206
|
+
|
|
207
|
+
for (const aid of ids) {
|
|
208
|
+
const existing = sessions.get(aid);
|
|
209
|
+
if (!existing || existing.mtime < mtime) {
|
|
210
|
+
sessions.set(aid, {
|
|
211
|
+
sessionId: sessId,
|
|
212
|
+
cwd: cwd || '',
|
|
213
|
+
mtime,
|
|
214
|
+
encodedDir: project,
|
|
215
|
+
});
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
log(` found ${sessions.size} agent session(s) in transcripts`);
|
|
221
|
+
return sessions;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
// Source 3: encoded project path → real cwd
|
|
226
|
+
// ---------------------------------------------------------------------------
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Claude Code encodes both `/` and `_` as `-` in project directory names,
|
|
230
|
+
* which is irreversible without filesystem inspection. This walks segments
|
|
231
|
+
* left-to-right, greedy-matching the longest prefix that exists on disk.
|
|
232
|
+
*/
|
|
233
|
+
function decodeCwdFromEncoded(encoded: string): string | null {
|
|
234
|
+
if (!encoded.startsWith('-')) return null;
|
|
235
|
+
let parts = encoded.slice(1).split('-');
|
|
236
|
+
let current = '/';
|
|
237
|
+
while (parts.length) {
|
|
238
|
+
let found: string | null = null;
|
|
239
|
+
let took = 1;
|
|
240
|
+
for (let take = parts.length; take > 0; take--) {
|
|
241
|
+
const candDash = parts.slice(0, take).join('-');
|
|
242
|
+
const candUs = parts.slice(0, take).join('_');
|
|
243
|
+
const baseDash = path.join(current, candDash);
|
|
244
|
+
const baseUs = path.join(current, candUs);
|
|
245
|
+
if (fs.existsSync(baseDash) && fs.statSync(baseDash).isDirectory()) {
|
|
246
|
+
found = candDash; took = take; break;
|
|
247
|
+
}
|
|
248
|
+
if (fs.existsSync(baseUs) && fs.statSync(baseUs).isDirectory()) {
|
|
249
|
+
found = candUs; took = take; break;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (!found) { found = parts[0]; took = 1; }
|
|
253
|
+
current = path.join(current, found);
|
|
254
|
+
parts = parts.slice(took);
|
|
255
|
+
}
|
|
256
|
+
return current;
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// ---------------------------------------------------------------------------
|
|
260
|
+
// Source 4: tmux liveness signal
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
|
|
263
|
+
interface TmuxInfo { createdAt: number; lastActivity: number; }
|
|
264
|
+
|
|
265
|
+
function getTmuxSessions(): Map<string, TmuxInfo> {
|
|
266
|
+
const map = new Map<string, TmuxInfo>();
|
|
267
|
+
const r = spawnSync('tmux', ['ls', '-F', '#{session_name} #{session_created} #{session_activity}'], { encoding: 'utf-8' });
|
|
268
|
+
if (r.status !== 0) return map;
|
|
269
|
+
for (const line of r.stdout.split('\n')) {
|
|
270
|
+
const parts = line.trim().split(/\s+/);
|
|
271
|
+
if (!parts[0]?.startsWith('tc-')) continue;
|
|
272
|
+
const aid = parts[0].slice(3);
|
|
273
|
+
if (!AGENT_ID_RE.test(aid)) continue;
|
|
274
|
+
map.set(aid, {
|
|
275
|
+
createdAt: parseInt(parts[1]) * 1000,
|
|
276
|
+
lastActivity: parseInt(parts[2]) * 1000,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
return map;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
// ---------------------------------------------------------------------------
|
|
283
|
+
// Source 5: running-processes.json (live agents with full state)
|
|
284
|
+
// ---------------------------------------------------------------------------
|
|
285
|
+
|
|
286
|
+
interface RunningProcess {
|
|
287
|
+
agentId: string;
|
|
288
|
+
sessionId?: string;
|
|
289
|
+
lastRequest?: any;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function loadRunningProcesses(dataDir: string): Map<string, RunningProcess> {
|
|
293
|
+
const map = new Map<string, RunningProcess>();
|
|
294
|
+
for (const fname of ['running-processes.json', 'running-processes.json.bak']) {
|
|
295
|
+
const fp = path.join(dataDir, fname);
|
|
296
|
+
const data = readJson<{ processes?: RunningProcess[] }>(fp);
|
|
297
|
+
if (data?.processes) {
|
|
298
|
+
for (const p of data.processes) {
|
|
299
|
+
if (!map.has(p.agentId)) map.set(p.agentId, p);
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
return map;
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// ---------------------------------------------------------------------------
|
|
307
|
+
// Spiral position layout — keeps recovered agents from stacking on top of each other
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
|
|
310
|
+
function spiralPosition(idx: number): { x: number; y: number; z: number } {
|
|
311
|
+
const a = 1.5, b = 0.55;
|
|
312
|
+
const t = idx * 0.6;
|
|
313
|
+
const r = a + b * t;
|
|
314
|
+
return {
|
|
315
|
+
x: parseFloat((r * Math.cos(t)).toFixed(6)),
|
|
316
|
+
y: 0,
|
|
317
|
+
z: parseFloat((r * Math.sin(t)).toFixed(6)),
|
|
318
|
+
};
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
// ---------------------------------------------------------------------------
|
|
322
|
+
// Build
|
|
323
|
+
// ---------------------------------------------------------------------------
|
|
324
|
+
|
|
325
|
+
function loadSeedAgents(dataDir: string): Map<string, StoredAgent> {
|
|
326
|
+
// Any existing agents.json or before-recovery backup is a strong seed:
|
|
327
|
+
// it has agents the logs may no longer reference.
|
|
328
|
+
const seed = new Map<string, StoredAgent>();
|
|
329
|
+
const candidates = [
|
|
330
|
+
path.join(dataDir, 'agents.json'),
|
|
331
|
+
...fs.readdirSync(dataDir)
|
|
332
|
+
.filter(f => f.startsWith('agents.json.before-recovery-'))
|
|
333
|
+
.sort()
|
|
334
|
+
.reverse() // newest first
|
|
335
|
+
.map(f => path.join(dataDir, f)),
|
|
336
|
+
path.join(dataDir, 'agents.json.bak'),
|
|
337
|
+
];
|
|
338
|
+
for (const fp of candidates) {
|
|
339
|
+
const data = readJson<{ agents?: StoredAgent[] }>(fp);
|
|
340
|
+
if (!data?.agents) continue;
|
|
341
|
+
for (const a of data.agents) {
|
|
342
|
+
if (!seed.has(a.id)) seed.set(a.id, a);
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
log(` loaded ${seed.size} agent(s) from existing agents.json + backups`);
|
|
346
|
+
return seed;
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
function build(): { agents: StoredAgent[]; stats: Record<string, number> } {
|
|
350
|
+
log('Loading sources…');
|
|
351
|
+
const delegation = readJson<{ histories: Record<string, any[]> }>(path.join(DATA_DIR, 'delegation-history.json')) || { histories: {} };
|
|
352
|
+
const sessionHist = readJson<{ histories: Record<string, any[]> }>(path.join(DATA_DIR, 'session-history.json')) || { histories: {} };
|
|
353
|
+
|
|
354
|
+
const seed = loadSeedAgents(DATA_DIR);
|
|
355
|
+
const meta = extractAgentMetaFromTranscripts();
|
|
356
|
+
const agentSessions = extractAgentSessionsFromTranscripts();
|
|
357
|
+
const tmux = getTmuxSessions();
|
|
358
|
+
const running = loadRunningProcesses(DATA_DIR);
|
|
359
|
+
|
|
360
|
+
// Boss IDs (agents with delegation history entries)
|
|
361
|
+
const bossIds = new Set(Object.keys(delegation.histories));
|
|
362
|
+
|
|
363
|
+
// Subordinate → most recent boss
|
|
364
|
+
const subordToBoss = new Map<string, { bossId: string; ts: number }>();
|
|
365
|
+
const bossSubords = new Map<string, Set<string>>();
|
|
366
|
+
for (const [bossId, entries] of Object.entries(delegation.histories)) {
|
|
367
|
+
const subs = new Set<string>();
|
|
368
|
+
for (const d of entries) {
|
|
369
|
+
const sid: string = d.selectedAgentId;
|
|
370
|
+
const ts: number = d.timestamp || 0;
|
|
371
|
+
if (!sid) continue;
|
|
372
|
+
subs.add(sid);
|
|
373
|
+
const cur = subordToBoss.get(sid);
|
|
374
|
+
if (!cur || cur.ts < ts) subordToBoss.set(sid, { bossId, ts });
|
|
375
|
+
}
|
|
376
|
+
if (subs.size) bossSubords.set(bossId, subs);
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
// ========== TARGET LIST ==========
|
|
380
|
+
// Include: anyone in the seed, anyone with name/class in logs, anyone running.
|
|
381
|
+
const targetIds = new Set<string>([...seed.keys(), ...meta.keys(), ...running.keys()]);
|
|
382
|
+
|
|
383
|
+
// Sort by recency (most recent activity first) for deterministic spiral
|
|
384
|
+
const activity = (id: string) => Math.max(
|
|
385
|
+
meta.get(id)?.mtime || 0,
|
|
386
|
+
agentSessions.get(id)?.mtime || 0,
|
|
387
|
+
Math.floor((tmux.get(id)?.lastActivity || 0) / 1000),
|
|
388
|
+
);
|
|
389
|
+
const sortedIds = [...targetIds].sort((a, b) => activity(b) - activity(a));
|
|
390
|
+
|
|
391
|
+
// ========== BUILD ==========
|
|
392
|
+
const agents: StoredAgent[] = [];
|
|
393
|
+
for (const [idx, aid] of sortedIds.entries()) {
|
|
394
|
+
const seedA = seed.get(aid);
|
|
395
|
+
const m = meta.get(aid);
|
|
396
|
+
const sess = agentSessions.get(aid);
|
|
397
|
+
const tm = tmux.get(aid);
|
|
398
|
+
const proc = running.get(aid);
|
|
399
|
+
|
|
400
|
+
// Name + class — priority: running process > transcripts > seed
|
|
401
|
+
let name = m?.name || seedA?.name;
|
|
402
|
+
let cls = m?.class || seedA?.class;
|
|
403
|
+
if (proc?.lastRequest?.customAgent) {
|
|
404
|
+
const ca = proc.lastRequest.customAgent;
|
|
405
|
+
if (ca.name) cls = ca.name;
|
|
406
|
+
const promptText: string = ca.definition?.prompt || '';
|
|
407
|
+
const nameMatch = promptText.match(/You are agent \*\*([^*]+)\*\*/);
|
|
408
|
+
if (nameMatch) name = nameMatch[1].trim();
|
|
409
|
+
}
|
|
410
|
+
if (!name) name = `Agent ${aid.slice(0, 6)}`;
|
|
411
|
+
if (!cls) cls = 'caterpie';
|
|
412
|
+
|
|
413
|
+
// CWD — priority: running process > seed > session > decoded encoded dir
|
|
414
|
+
let cwd = seedA?.cwd || '/home/riven/d/';
|
|
415
|
+
if (proc?.lastRequest?.workingDir) {
|
|
416
|
+
cwd = proc.lastRequest.workingDir;
|
|
417
|
+
} else if (sess?.cwd) {
|
|
418
|
+
cwd = sess.cwd;
|
|
419
|
+
} else if (sess?.encodedDir) {
|
|
420
|
+
cwd = decodeCwdFromEncoded(sess.encodedDir) || cwd;
|
|
421
|
+
}
|
|
422
|
+
if (!cwd.endsWith('/')) cwd += '/';
|
|
423
|
+
|
|
424
|
+
// sessionId — priority: running process > new scan > seed
|
|
425
|
+
const sessionId = proc?.sessionId || sess?.sessionId || seedA?.sessionId;
|
|
426
|
+
|
|
427
|
+
// Timestamps — prefer tmux (most accurate), then session, then transcript mtime, then seed
|
|
428
|
+
let createdAt: number;
|
|
429
|
+
let lastActivity: number;
|
|
430
|
+
if (tm) {
|
|
431
|
+
createdAt = tm.createdAt;
|
|
432
|
+
lastActivity = tm.lastActivity;
|
|
433
|
+
} else if (sess) {
|
|
434
|
+
createdAt = sess.mtime * 1000;
|
|
435
|
+
lastActivity = sess.mtime * 1000;
|
|
436
|
+
} else if (seedA) {
|
|
437
|
+
createdAt = seedA.createdAt;
|
|
438
|
+
lastActivity = seedA.lastActivity;
|
|
439
|
+
} else {
|
|
440
|
+
createdAt = (m?.mtime || Math.floor(Date.now() / 1000)) * 1000;
|
|
441
|
+
lastActivity = createdAt;
|
|
442
|
+
}
|
|
443
|
+
// Pull min/max from session-history if available
|
|
444
|
+
const sh = sessionHist.histories[aid];
|
|
445
|
+
if (sh?.length) {
|
|
446
|
+
const earliest = Math.min(...sh.map((s: any) => s.startedAt));
|
|
447
|
+
const latest = Math.max(...sh.map((s: any) => s.endedAt));
|
|
448
|
+
if (earliest && earliest < createdAt) createdAt = earliest;
|
|
449
|
+
if (latest && latest > lastActivity) lastActivity = latest;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
const agent: StoredAgent = {
|
|
453
|
+
id: aid,
|
|
454
|
+
name,
|
|
455
|
+
class: cls,
|
|
456
|
+
provider: 'claude',
|
|
457
|
+
position: spiralPosition(idx),
|
|
458
|
+
cwd,
|
|
459
|
+
tokensUsed: 0,
|
|
460
|
+
contextUsed: 0,
|
|
461
|
+
contextLimit: 200000,
|
|
462
|
+
taskCount: 0,
|
|
463
|
+
permissionMode: 'bypass',
|
|
464
|
+
useChrome: false,
|
|
465
|
+
model: 'sonnet',
|
|
466
|
+
createdAt,
|
|
467
|
+
lastActivity,
|
|
468
|
+
};
|
|
469
|
+
|
|
470
|
+
if (sessionId) agent.sessionId = sessionId;
|
|
471
|
+
if (bossIds.has(aid)) {
|
|
472
|
+
agent.isBoss = true;
|
|
473
|
+
const subs = [...(bossSubords.get(aid) || [])].filter(s => targetIds.has(s));
|
|
474
|
+
if (subs.length) agent.subordinateIds = subs;
|
|
475
|
+
} else {
|
|
476
|
+
const sb = subordToBoss.get(aid);
|
|
477
|
+
if (sb && targetIds.has(sb.bossId)) agent.bossId = sb.bossId;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// Pull running-process model/permission overrides
|
|
481
|
+
if (proc?.lastRequest) {
|
|
482
|
+
agent.model = proc.lastRequest.model || agent.model;
|
|
483
|
+
agent.useChrome = proc.lastRequest.useChrome ?? agent.useChrome;
|
|
484
|
+
agent.permissionMode = proc.lastRequest.permissionMode || agent.permissionMode;
|
|
485
|
+
}
|
|
486
|
+
|
|
487
|
+
agents.push(agent);
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
const stats = {
|
|
491
|
+
total: agents.length,
|
|
492
|
+
boss: agents.filter(a => a.isBoss).length,
|
|
493
|
+
withSession: agents.filter(a => a.sessionId).length,
|
|
494
|
+
liveTmux: agents.filter(a => tmux.has(a.id)).length,
|
|
495
|
+
runningProcesses: agents.filter(a => running.has(a.id)).length,
|
|
496
|
+
};
|
|
497
|
+
|
|
498
|
+
return { agents, stats };
|
|
499
|
+
}
|
|
500
|
+
|
|
501
|
+
// ---------------------------------------------------------------------------
|
|
502
|
+
// Apply (server restart sequence)
|
|
503
|
+
// ---------------------------------------------------------------------------
|
|
504
|
+
|
|
505
|
+
async function sleep(ms: number): Promise<void> { return new Promise(r => setTimeout(r, ms)); }
|
|
506
|
+
|
|
507
|
+
async function checkServer(): Promise<boolean> {
|
|
508
|
+
try {
|
|
509
|
+
const r = await fetch(`${SERVER_URL}/api/agents/simple`, { headers: { 'X-Auth-Token': AUTH_TOKEN } });
|
|
510
|
+
return r.ok;
|
|
511
|
+
} catch { return false; }
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
async function findServerPid(): Promise<number | null> {
|
|
515
|
+
try {
|
|
516
|
+
const out = execSync(`pgrep -f "node.*src/packages/server/index.ts" || true`, { encoding: 'utf-8' });
|
|
517
|
+
const pid = parseInt(out.trim().split('\n').pop() || '');
|
|
518
|
+
return Number.isFinite(pid) && pid > 0 ? pid : null;
|
|
519
|
+
} catch { return null; }
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
async function apply(stagedFile: string, agentsFile: string): Promise<void> {
|
|
523
|
+
log('');
|
|
524
|
+
log('=== APPLY ===');
|
|
525
|
+
|
|
526
|
+
const wasUp = await checkServer();
|
|
527
|
+
if (!wasUp) {
|
|
528
|
+
warn('Server is not running — copying files but skipping restart.');
|
|
529
|
+
fs.copyFileSync(stagedFile, agentsFile);
|
|
530
|
+
fs.rmSync(agentsFile + '.bak', { force: true });
|
|
531
|
+
log('Wrote agents.json. Start the server and it will load the recovered agents.');
|
|
532
|
+
return;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
const pid = await findServerPid();
|
|
536
|
+
if (!pid) {
|
|
537
|
+
throw new Error('Could not find server process (looked for "node.*src/packages/server/index.ts"). Stop the server manually, run again with --apply.');
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
log(`Killing server (PID ${pid})…`);
|
|
541
|
+
process.kill(pid, 'SIGTERM');
|
|
542
|
+
|
|
543
|
+
// Wait for port to free
|
|
544
|
+
for (let i = 0; i < 30; i++) {
|
|
545
|
+
if (!await checkServer()) { log(` port freed after ${i * 200}ms`); break; }
|
|
546
|
+
await sleep(200);
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
log('Copying recovered file…');
|
|
550
|
+
fs.copyFileSync(stagedFile, agentsFile);
|
|
551
|
+
fs.rmSync(agentsFile + '.bak', { force: true });
|
|
552
|
+
|
|
553
|
+
log('Triggering tsx watch restart (touch index.ts)…');
|
|
554
|
+
const now = Date.now() / 1000;
|
|
555
|
+
fs.utimesSync(SERVER_FILE, now, now);
|
|
556
|
+
|
|
557
|
+
log('Waiting for server to come back…');
|
|
558
|
+
for (let i = 0; i < 60; i++) {
|
|
559
|
+
if (await checkServer()) { log(` server back after ${i + 1}s`); break; }
|
|
560
|
+
await sleep(1000);
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
// Verify
|
|
564
|
+
await sleep(2000);
|
|
565
|
+
const r = await fetch(`${SERVER_URL}/api/agents/simple`, { headers: { 'X-Auth-Token': AUTH_TOKEN } });
|
|
566
|
+
const live = await r.json();
|
|
567
|
+
const onDisk = readJson<{ agents: any[] }>(agentsFile);
|
|
568
|
+
log(`Server: ${live.length} agent(s) in memory; disk: ${onDisk?.agents.length ?? 0}`);
|
|
569
|
+
if (live.length < 5) {
|
|
570
|
+
warn('Server reports very few agents — check the data dir manually.');
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
// ---------------------------------------------------------------------------
|
|
575
|
+
// Main
|
|
576
|
+
// ---------------------------------------------------------------------------
|
|
577
|
+
|
|
578
|
+
async function main(): Promise<void> {
|
|
579
|
+
if (!fs.existsSync(DATA_DIR)) {
|
|
580
|
+
console.error(`Data dir not found: ${DATA_DIR}`);
|
|
581
|
+
process.exit(1);
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
const agentsFile = path.join(DATA_DIR, 'agents.json');
|
|
585
|
+
const ts = Date.now();
|
|
586
|
+
|
|
587
|
+
// Back up current agents.json (if any) before we even attempt anything
|
|
588
|
+
if (fs.existsSync(agentsFile)) {
|
|
589
|
+
const backup = `${agentsFile}.before-recovery-${ts}`;
|
|
590
|
+
fs.copyFileSync(agentsFile, backup);
|
|
591
|
+
log(`Backed up current agents.json → ${path.basename(backup)}`);
|
|
592
|
+
}
|
|
593
|
+
|
|
594
|
+
const { agents, stats } = build();
|
|
595
|
+
|
|
596
|
+
log('');
|
|
597
|
+
log('=== STATS ===');
|
|
598
|
+
for (const [k, v] of Object.entries(stats)) log(` ${k}: ${v}`);
|
|
599
|
+
|
|
600
|
+
const out = { agents, savedAt: Date.now(), version: '1.0.0' };
|
|
601
|
+
fs.writeFileSync(OUTPUT, JSON.stringify(out, null, 2));
|
|
602
|
+
log('');
|
|
603
|
+
log(`Wrote ${stats.total} agent(s) → ${OUTPUT}`);
|
|
604
|
+
|
|
605
|
+
if (APPLY) {
|
|
606
|
+
await apply(OUTPUT, agentsFile);
|
|
607
|
+
} else {
|
|
608
|
+
log('');
|
|
609
|
+
log('Dry run complete. To apply:');
|
|
610
|
+
log(` tsx scripts/recover-agents.ts --apply`);
|
|
611
|
+
log('');
|
|
612
|
+
log('Or manually:');
|
|
613
|
+
log(` 1. Stop the Tide Commander server`);
|
|
614
|
+
log(` 2. cp ${OUTPUT} ${agentsFile}`);
|
|
615
|
+
log(` 3. rm -f ${agentsFile}.bak`);
|
|
616
|
+
log(` 4. Restart the server`);
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
main().catch(err => {
|
|
621
|
+
console.error('Recovery failed:', err);
|
|
622
|
+
process.exit(1);
|
|
623
|
+
});
|