@proletariat/cli 0.3.109 → 0.3.111

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.
Files changed (51) hide show
  1. package/dist/commands/orchestrator/attach.d.ts +2 -0
  2. package/dist/commands/orchestrator/attach.js +80 -118
  3. package/dist/commands/orchestrator/attach.js.map +1 -1
  4. package/dist/commands/orchestrator/start.js +21 -0
  5. package/dist/commands/orchestrator/start.js.map +1 -1
  6. package/dist/commands/orchestrator/status.d.ts +3 -0
  7. package/dist/commands/orchestrator/status.js +104 -130
  8. package/dist/commands/orchestrator/status.js.map +1 -1
  9. package/dist/commands/orchestrator/stop.d.ts +2 -0
  10. package/dist/commands/orchestrator/stop.js +105 -107
  11. package/dist/commands/orchestrator/stop.js.map +1 -1
  12. package/dist/commands/session/attach.d.ts +2 -6
  13. package/dist/commands/session/attach.js +68 -97
  14. package/dist/commands/session/attach.js.map +1 -1
  15. package/dist/commands/session/list.d.ts +4 -1
  16. package/dist/commands/session/list.js +160 -326
  17. package/dist/commands/session/list.js.map +1 -1
  18. package/dist/commands/work/start.js +104 -49
  19. package/dist/commands/work/start.js.map +1 -1
  20. package/dist/lib/execution/session-utils.d.ts +4 -1
  21. package/dist/lib/execution/session-utils.js +3 -0
  22. package/dist/lib/execution/session-utils.js.map +1 -1
  23. package/dist/lib/machine-db-mirror.d.ts +64 -0
  24. package/dist/lib/machine-db-mirror.js +82 -0
  25. package/dist/lib/machine-db-mirror.js.map +1 -0
  26. package/dist/lib/machine-db.d.ts +11 -0
  27. package/dist/lib/machine-db.js +17 -0
  28. package/dist/lib/machine-db.js.map +1 -1
  29. package/dist/lib/orchestrate/actions.d.ts +8 -0
  30. package/dist/lib/orchestrate/actions.js +166 -94
  31. package/dist/lib/orchestrate/actions.js.map +1 -1
  32. package/dist/lib/orchestrate/prompt-chain.d.ts +181 -0
  33. package/dist/lib/orchestrate/prompt-chain.js +323 -0
  34. package/dist/lib/orchestrate/prompt-chain.js.map +1 -0
  35. package/dist/lib/prompt-command.d.ts +61 -1
  36. package/dist/lib/prompt-command.js +167 -1
  37. package/dist/lib/prompt-command.js.map +1 -1
  38. package/dist/lib/prompt-json.d.ts +129 -2
  39. package/dist/lib/prompt-json.js +157 -0
  40. package/dist/lib/prompt-json.js.map +1 -1
  41. package/dist/lib/runtime-command.d.ts +3 -1
  42. package/dist/lib/runtime-command.js +4 -2
  43. package/dist/lib/runtime-command.js.map +1 -1
  44. package/dist/lib/session/renderer.d.ts +121 -0
  45. package/dist/lib/session/renderer.js +547 -0
  46. package/dist/lib/session/renderer.js.map +1 -0
  47. package/dist/lib/update-check.d.ts +64 -7
  48. package/dist/lib/update-check.js +164 -20
  49. package/dist/lib/update-check.js.map +1 -1
  50. package/oclif.manifest.json +1173 -1062
  51. package/package.json +1 -1
@@ -0,0 +1,121 @@
1
+ /**
2
+ * Session Renderer (PRLT-1272)
3
+ *
4
+ * Unified machine-wide session collection and grouped rendering.
5
+ *
6
+ * The principle: `prlt session list`, `prlt orchestrator status`, and related
7
+ * commands should ALWAYS query machine-wide for all sessions, regardless of
8
+ * whether the user is inside an HQ. Sessions are then visually grouped by
9
+ * their originating HQ — the current HQ bubbles to the top, other locations
10
+ * are still visible but de-emphasized.
11
+ *
12
+ * Sources collected:
13
+ * 1. machine.db `executions` table — cross-repo ticketless and ticketed work
14
+ * 2. Each registered HQ's workspace.db `agent_work` table — per-HQ workers
15
+ * 3. Discovered tmux/Docker orchestrator sessions — detected by name prefix
16
+ *
17
+ * Each entry is annotated with its hqPath/hqName/role so the renderer can
18
+ * group them.
19
+ */
20
+ export type SessionRole = 'orchestrator' | 'worker' | 'headless';
21
+ export type SessionStatus = 'starting' | 'running' | 'idle' | 'died' | 'stale' | 'stopped' | 'completed' | 'failed';
22
+ /**
23
+ * Where a session record came from.
24
+ * - `db`: tracked in workspace.db or machine.db
25
+ * - `discovered`: found via tmux/Docker prefix scan (orphan)
26
+ */
27
+ export type SessionSource = 'db' | 'discovered';
28
+ /**
29
+ * A unified session record representing one running agent on the machine.
30
+ *
31
+ * Sessions originate from three sources:
32
+ * - workspace.db (per-HQ agent_work table) → workers, ticketed
33
+ * - machine.db (cross-repo executions table) → ticketless
34
+ * - tmux/Docker discovery → orchestrators (named prlt-orchestrator-*)
35
+ */
36
+ export interface UnifiedSession {
37
+ /** Stable identifier — sessionId where present, else execution id */
38
+ id: string;
39
+ /** tmux session name (or container synthetic id) */
40
+ sessionId: string;
41
+ /** Ticket id (e.g., 'PRLT-123', 'orchestrator', 'headless') */
42
+ ticketId: string;
43
+ /** Agent name (display) */
44
+ agentName: string;
45
+ /** Status — running/idle/stale/etc. */
46
+ status: SessionStatus;
47
+ /** Role — orchestrator vs worker vs headless */
48
+ role: SessionRole;
49
+ /** Execution environment */
50
+ environment: 'host' | 'container';
51
+ /** Container id (for container envs) */
52
+ containerId?: string;
53
+ /** Whether the runtime is verified alive */
54
+ exists: boolean;
55
+ /** Where the record was sourced from (backward compat field) */
56
+ source: SessionSource;
57
+ /** When this session started (best effort) */
58
+ startedAt?: Date;
59
+ /** Last heartbeat (workers only) */
60
+ lastHeartbeat?: Date;
61
+ /** Lifecycle state from DB (workers only) */
62
+ lifecycleState?: string;
63
+ /** HQ path this session originated from, if known */
64
+ hqPath?: string;
65
+ /** HQ name (display) */
66
+ hqName?: string;
67
+ /** Repo path the agent ran in (machine.db only, may differ from HQ) */
68
+ repoPath?: string;
69
+ }
70
+ export interface CollectOptions {
71
+ /** Include only sessions in this HQ path */
72
+ hqPathFilter?: string;
73
+ /** Filter to specific role */
74
+ roleFilter?: SessionRole;
75
+ /** Include stopped/completed sessions (default: only active runtimes) */
76
+ includeAll?: boolean;
77
+ }
78
+ export interface GroupedSessions {
79
+ /** Path of the HQ the user is currently in (resolved from cwd), if any */
80
+ currentHq?: string;
81
+ currentHqName?: string;
82
+ /** Sessions belonging to the current HQ */
83
+ here: UnifiedSession[];
84
+ /** Sessions in other HQs or headless */
85
+ elsewhere: UnifiedSession[];
86
+ }
87
+ /**
88
+ * Replace the user's home directory in a path with `~` for display.
89
+ */
90
+ export declare function tildifyPath(p: string): string;
91
+ /**
92
+ * Collect all sessions on the machine, regardless of cwd.
93
+ *
94
+ * Queries:
95
+ * 1. machine.db (cross-repo executions, source of truth for ticketless work)
96
+ * 2. Every registered HQ's workspace.db (workers per HQ)
97
+ * 3. tmux/Docker (orchestrators by name prefix)
98
+ *
99
+ * Filters are applied at the end so the underlying machine query path is
100
+ * the same in every command.
101
+ */
102
+ export declare function collectAllSessions(options?: CollectOptions): UnifiedSession[];
103
+ /**
104
+ * Group sessions into "current HQ" vs "other locations" buckets.
105
+ *
106
+ * The current HQ is resolved from `process.cwd()` (or an explicit override).
107
+ * Sessions whose hqPath matches the current HQ go into `here`; everything
108
+ * else goes into `elsewhere`.
109
+ */
110
+ export declare function groupSessionsByHQ(sessions: UnifiedSession[], currentHqOverride?: string | null): GroupedSessions;
111
+ /** Format a Date into a compact relative duration like "~15m", "~3h", "~2d". */
112
+ export declare function formatRelativeAge(start?: Date, now?: Date): string;
113
+ /**
114
+ * Group elsewhere sessions by hqPath/hqName for nicer rendering.
115
+ * Sessions without an hqPath are bucketed under "(no HQ)".
116
+ */
117
+ export declare function bucketElsewhereByHq(elsewhere: UnifiedSession[]): Array<{
118
+ hqPath: string | null;
119
+ hqName: string;
120
+ sessions: UnifiedSession[];
121
+ }>;
@@ -0,0 +1,547 @@
1
+ /**
2
+ * Session Renderer (PRLT-1272)
3
+ *
4
+ * Unified machine-wide session collection and grouped rendering.
5
+ *
6
+ * The principle: `prlt session list`, `prlt orchestrator status`, and related
7
+ * commands should ALWAYS query machine-wide for all sessions, regardless of
8
+ * whether the user is inside an HQ. Sessions are then visually grouped by
9
+ * their originating HQ — the current HQ bubbles to the top, other locations
10
+ * are still visible but de-emphasized.
11
+ *
12
+ * Sources collected:
13
+ * 1. machine.db `executions` table — cross-repo ticketless and ticketed work
14
+ * 2. Each registered HQ's workspace.db `agent_work` table — per-HQ workers
15
+ * 3. Discovered tmux/Docker orchestrator sessions — detected by name prefix
16
+ *
17
+ * Each entry is annotated with its hqPath/hqName/role so the renderer can
18
+ * group them.
19
+ */
20
+ import * as fs from 'node:fs';
21
+ import * as os from 'node:os';
22
+ import * as path from 'node:path';
23
+ import { execSync } from 'node:child_process';
24
+ import { ExecutionStorage } from '../execution/storage.js';
25
+ import { openWorkspaceDatabase } from '../database/index.js';
26
+ import { MachineDB } from '../machine-db.js';
27
+ import { getRegisteredHeadquarters, getHeadquartersNameFromPath, normalizePath, } from '../machine-config.js';
28
+ import { checkContainerLiveness, discoverSessionId, findContainerSessionsByPrefix, getContainerTmuxSessionMap, getHostTmuxSessionNames, isContainerEnvironment, isSessionAlive, } from '../execution/session-utils.js';
29
+ import { findHQRoot } from '../workspace.js';
30
+ function captureRuntimeSnapshot() {
31
+ return {
32
+ hostTmuxSessions: getHostTmuxSessionNames(),
33
+ containerTmuxSessions: getContainerTmuxSessionMap(),
34
+ };
35
+ }
36
+ function findRunningOrchestratorContainers() {
37
+ try {
38
+ const output = execSync('docker ps --filter "name=prlt-orchestrator-" --format "{{.Names}}\t{{.ID}}"', { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
39
+ if (!output)
40
+ return [];
41
+ return output
42
+ .split('\n')
43
+ .filter(Boolean)
44
+ .map(line => {
45
+ const [name, id] = line.split('\t');
46
+ return { name, containerId: id };
47
+ });
48
+ }
49
+ catch {
50
+ return [];
51
+ }
52
+ }
53
+ // =============================================================================
54
+ // HQ resolution helpers
55
+ // =============================================================================
56
+ /**
57
+ * Sanitize a name segment for use in tmux session names.
58
+ * Mirror of orchestrator/start.ts sanitizeName() — duplicated here to avoid
59
+ * importing a command file from a lib helper (cycle risk).
60
+ */
61
+ function sanitizeName(name) {
62
+ return name
63
+ .replace(/[^a-zA-Z0-9._-]/g, '-')
64
+ .replace(/-+/g, '-')
65
+ .replace(/^-|-$/g, '');
66
+ }
67
+ /**
68
+ * Build the orchestrator session prefix used by `prlt orchestrator start`.
69
+ * Format: `prlt-orchestrator-{sanitizedHqName}-`
70
+ */
71
+ function orchestratorPrefixForHq(hqName) {
72
+ return `prlt-orchestrator-${sanitizeName(hqName) || 'default'}-`;
73
+ }
74
+ /**
75
+ * Try to attribute an orchestrator session/container name to one of the
76
+ * registered HQs. Returns the matching HQ entry, or null if no match.
77
+ */
78
+ function attributeOrchestratorToHq(sessionName, registered) {
79
+ for (const hq of registered) {
80
+ const hqName = getHeadquartersNameFromPath(hq.path);
81
+ const prefix = orchestratorPrefixForHq(hqName);
82
+ if (sessionName.startsWith(prefix)) {
83
+ return {
84
+ hqPath: hq.path,
85
+ hqName,
86
+ orchestratorName: sessionName.slice(prefix.length),
87
+ };
88
+ }
89
+ }
90
+ return null;
91
+ }
92
+ /**
93
+ * Replace the user's home directory in a path with `~` for display.
94
+ */
95
+ export function tildifyPath(p) {
96
+ if (!p)
97
+ return p;
98
+ const home = process.env.HOME || os.homedir();
99
+ if (p === home)
100
+ return '~';
101
+ if (p.startsWith(home + path.sep))
102
+ return '~' + p.slice(home.length);
103
+ return p;
104
+ }
105
+ // =============================================================================
106
+ // Workspace.db (per-HQ) collection
107
+ // =============================================================================
108
+ function collectFromWorkspaceDb(hqPath, hqName, snapshot, includeAll) {
109
+ const sessions = [];
110
+ let db = null;
111
+ try {
112
+ db = openWorkspaceDatabase(hqPath);
113
+ }
114
+ catch {
115
+ return sessions;
116
+ }
117
+ try {
118
+ const storage = new ExecutionStorage(db);
119
+ // Active executions (running + starting)
120
+ const active = [
121
+ ...storage.listExecutions({ status: 'running' }),
122
+ ...storage.listExecutions({ status: 'starting' }),
123
+ ];
124
+ // Self-healing: also check stopped records that may have a live tmux session
125
+ if (includeAll) {
126
+ active.push(...storage.listExecutions({ status: 'stopped' }));
127
+ }
128
+ else {
129
+ // Promote any stopped executions whose tmux sessions are still alive.
130
+ const stopped = storage.listExecutions({ status: 'stopped' });
131
+ for (const exec of stopped) {
132
+ if (isSessionAlive(exec)) {
133
+ active.push(exec);
134
+ }
135
+ }
136
+ }
137
+ for (const exec of active) {
138
+ const isContainer = isContainerEnvironment(exec.environment);
139
+ let exists = false;
140
+ let actualSessionId = discoverSessionId(exec, snapshot.hostTmuxSessions, snapshot.containerTmuxSessions) ||
141
+ exec.sessionId;
142
+ if (isContainer && exec.containerId) {
143
+ const containerStatus = checkContainerLiveness(exec.containerId);
144
+ if (containerStatus === 'running') {
145
+ if (actualSessionId) {
146
+ const containerSessions = findContainerSessionsByPrefix(snapshot.containerTmuxSessions, exec.containerId);
147
+ exists = containerSessions.includes(actualSessionId);
148
+ }
149
+ if (!exists) {
150
+ // Container running but no tmux session — still counts as alive.
151
+ exists = true;
152
+ actualSessionId =
153
+ actualSessionId || `container:${exec.containerId.substring(0, 12)}`;
154
+ }
155
+ }
156
+ }
157
+ else if (actualSessionId) {
158
+ exists = snapshot.hostTmuxSessions.includes(actualSessionId);
159
+ }
160
+ if (!actualSessionId)
161
+ continue;
162
+ if (!exists && !includeAll)
163
+ continue;
164
+ // Determine role: agentName 'orchestrator-*' or ticketId 'ORCH'
165
+ let role = 'worker';
166
+ if (exec.agentName.startsWith('orchestrator') ||
167
+ exec.ticketId === 'ORCH' ||
168
+ exec.ticketId === 'orchestrator') {
169
+ role = 'orchestrator';
170
+ }
171
+ sessions.push({
172
+ id: exec.id,
173
+ sessionId: actualSessionId,
174
+ ticketId: exec.ticketId,
175
+ agentName: exec.agentName,
176
+ status: deriveStatus(exec.status, exists, exec.lifecycleState),
177
+ role,
178
+ environment: isContainer ? 'container' : 'host',
179
+ containerId: exec.containerId,
180
+ exists,
181
+ source: 'db',
182
+ startedAt: exec.startedAt,
183
+ lastHeartbeat: exec.lastHeartbeat,
184
+ lifecycleState: exec.lifecycleState,
185
+ hqPath,
186
+ hqName,
187
+ repoPath: hqPath,
188
+ });
189
+ }
190
+ }
191
+ finally {
192
+ db.close();
193
+ }
194
+ return sessions;
195
+ }
196
+ function deriveStatus(rawStatus, exists, lifecycleState) {
197
+ if (lifecycleState === 'died')
198
+ return 'died';
199
+ if (lifecycleState === 'idle')
200
+ return 'idle';
201
+ if (!exists)
202
+ return 'stale';
203
+ return rawStatus || 'running';
204
+ }
205
+ // =============================================================================
206
+ // machine.db collection
207
+ // =============================================================================
208
+ function collectFromMachineDb(snapshot, registered, alreadySeenSessionIds, includeAll) {
209
+ const sessions = [];
210
+ let db = null;
211
+ try {
212
+ db = new MachineDB();
213
+ }
214
+ catch {
215
+ return sessions;
216
+ }
217
+ try {
218
+ const machineExecs = includeAll
219
+ ? db.listExecutions()
220
+ : db.getActiveExecutions();
221
+ for (const exec of machineExecs) {
222
+ // Skip duplicates already collected from a workspace.db.
223
+ if (exec.sessionId && alreadySeenSessionIds.has(exec.sessionId))
224
+ continue;
225
+ const isContainer = exec.environment === 'docker' || exec.environment === 'devcontainer';
226
+ let exists = false;
227
+ let actualSessionId = exec.sessionId;
228
+ if (isContainer && exec.containerId) {
229
+ const status = checkContainerLiveness(exec.containerId);
230
+ if (status === 'running') {
231
+ if (exec.sessionId) {
232
+ const containerSessions = findContainerSessionsByPrefix(snapshot.containerTmuxSessions, exec.containerId);
233
+ exists = containerSessions.includes(exec.sessionId);
234
+ }
235
+ if (!exists) {
236
+ exists = true;
237
+ actualSessionId =
238
+ actualSessionId || `container:${exec.containerId.substring(0, 12)}`;
239
+ }
240
+ }
241
+ }
242
+ else if (exec.sessionId) {
243
+ exists = snapshot.hostTmuxSessions.includes(exec.sessionId);
244
+ }
245
+ if (!actualSessionId)
246
+ continue;
247
+ if (!exists && !includeAll)
248
+ continue;
249
+ // Attribute repoPath to a registered HQ if possible.
250
+ const matchedHq = matchRepoToHq(exec.repoPath, registered);
251
+ // Detect role
252
+ let role = exec.ticketId ? 'worker' : 'headless';
253
+ if (exec.agentName.startsWith('orchestrator') ||
254
+ exec.ticketId === 'ORCH' ||
255
+ exec.ticketId === 'orchestrator') {
256
+ role = 'orchestrator';
257
+ }
258
+ sessions.push({
259
+ id: exec.id,
260
+ sessionId: actualSessionId,
261
+ ticketId: exec.ticketId || exec.id,
262
+ agentName: exec.agentName,
263
+ status: deriveStatus(exec.status, exists),
264
+ role,
265
+ environment: isContainer ? 'container' : 'host',
266
+ containerId: exec.containerId,
267
+ exists,
268
+ source: 'db',
269
+ startedAt: exec.startedAt,
270
+ hqPath: matchedHq?.hqPath,
271
+ hqName: matchedHq?.hqName,
272
+ repoPath: exec.repoPath,
273
+ });
274
+ }
275
+ }
276
+ finally {
277
+ db.close();
278
+ }
279
+ return sessions;
280
+ }
281
+ /**
282
+ * Match a repo path to one of the registered HQs.
283
+ * A repo is considered to belong to an HQ if it lives inside the HQ tree
284
+ * (e.g. an agent worktree under `<hqPath>/agents/...`).
285
+ */
286
+ function matchRepoToHq(repoPath, registered) {
287
+ if (!repoPath)
288
+ return null;
289
+ let normalized;
290
+ try {
291
+ normalized = normalizePath(repoPath);
292
+ }
293
+ catch {
294
+ normalized = repoPath;
295
+ }
296
+ for (const hq of registered) {
297
+ let hqNormalized;
298
+ try {
299
+ hqNormalized = normalizePath(hq.path);
300
+ }
301
+ catch {
302
+ hqNormalized = hq.path;
303
+ }
304
+ if (normalized === hqNormalized || normalized.startsWith(hqNormalized + path.sep)) {
305
+ return { hqPath: hq.path, hqName: getHeadquartersNameFromPath(hq.path) };
306
+ }
307
+ }
308
+ return null;
309
+ }
310
+ // =============================================================================
311
+ // Orchestrator discovery (tmux + Docker)
312
+ // =============================================================================
313
+ function collectOrchestrators(snapshot, registered, alreadySeenSessionIds) {
314
+ const sessions = [];
315
+ // Host tmux orchestrator sessions
316
+ for (const sessionName of snapshot.hostTmuxSessions) {
317
+ if (!sessionName.startsWith('prlt-orchestrator-'))
318
+ continue;
319
+ if (alreadySeenSessionIds.has(sessionName))
320
+ continue;
321
+ const attribution = attributeOrchestratorToHq(sessionName, registered);
322
+ sessions.push({
323
+ id: sessionName,
324
+ sessionId: sessionName,
325
+ ticketId: 'orchestrator',
326
+ agentName: attribution?.orchestratorName || sessionName,
327
+ status: 'running',
328
+ role: 'orchestrator',
329
+ environment: 'host',
330
+ exists: true,
331
+ source: 'discovered',
332
+ hqPath: attribution?.hqPath,
333
+ hqName: attribution?.hqName,
334
+ repoPath: attribution?.hqPath,
335
+ });
336
+ }
337
+ // Docker orchestrator containers
338
+ const dockerOrchestrators = findRunningOrchestratorContainers();
339
+ for (const { name, containerId } of dockerOrchestrators) {
340
+ if (alreadySeenSessionIds.has(name))
341
+ continue;
342
+ const attribution = attributeOrchestratorToHq(name, registered);
343
+ sessions.push({
344
+ id: name,
345
+ sessionId: name,
346
+ ticketId: 'orchestrator',
347
+ agentName: attribution?.orchestratorName || name,
348
+ status: 'running',
349
+ role: 'orchestrator',
350
+ environment: 'container',
351
+ containerId,
352
+ exists: true,
353
+ source: 'discovered',
354
+ hqPath: attribution?.hqPath,
355
+ hqName: attribution?.hqName,
356
+ repoPath: attribution?.hqPath,
357
+ });
358
+ }
359
+ return sessions;
360
+ }
361
+ // =============================================================================
362
+ // Public API: collect + group
363
+ // =============================================================================
364
+ /**
365
+ * Collect all sessions on the machine, regardless of cwd.
366
+ *
367
+ * Queries:
368
+ * 1. machine.db (cross-repo executions, source of truth for ticketless work)
369
+ * 2. Every registered HQ's workspace.db (workers per HQ)
370
+ * 3. tmux/Docker (orchestrators by name prefix)
371
+ *
372
+ * Filters are applied at the end so the underlying machine query path is
373
+ * the same in every command.
374
+ */
375
+ export function collectAllSessions(options = {}) {
376
+ const snapshot = captureRuntimeSnapshot();
377
+ const registered = getRegisteredHeadquarters().filter(hq => fs.existsSync(hq.path));
378
+ // Include the current cwd HQ even when it isn't in the machine registry
379
+ // (e.g. e2e tests that set up a throwaway HQ without registering it).
380
+ const cwdHq = findHQRoot(process.cwd());
381
+ const hqsToQuery = [...registered];
382
+ if (cwdHq) {
383
+ let cwdNormalized;
384
+ try {
385
+ cwdNormalized = normalizePath(cwdHq);
386
+ }
387
+ catch {
388
+ cwdNormalized = cwdHq;
389
+ }
390
+ const alreadyRegistered = registered.some(hq => {
391
+ try {
392
+ return normalizePath(hq.path) === cwdNormalized;
393
+ }
394
+ catch {
395
+ return hq.path === cwdNormalized;
396
+ }
397
+ });
398
+ if (!alreadyRegistered) {
399
+ hqsToQuery.push({
400
+ name: getHeadquartersNameFromPath(cwdHq),
401
+ path: cwdHq,
402
+ registeredAt: new Date().toISOString(),
403
+ });
404
+ }
405
+ }
406
+ const seenSessionIds = new Set();
407
+ const all = [];
408
+ // 1) Per-HQ workspace databases (workers + locally tracked orchestrators).
409
+ for (const hq of hqsToQuery) {
410
+ const hqName = getHeadquartersNameFromPath(hq.path);
411
+ const fromHq = collectFromWorkspaceDb(hq.path, hqName, snapshot, options.includeAll === true);
412
+ for (const s of fromHq) {
413
+ if (s.sessionId)
414
+ seenSessionIds.add(s.sessionId);
415
+ all.push(s);
416
+ }
417
+ }
418
+ // 2) machine.db ticketless / cross-repo executions
419
+ const fromMachine = collectFromMachineDb(snapshot, hqsToQuery, seenSessionIds, options.includeAll === true);
420
+ for (const s of fromMachine) {
421
+ if (s.sessionId)
422
+ seenSessionIds.add(s.sessionId);
423
+ all.push(s);
424
+ }
425
+ // 3) Discovered orchestrator tmux/Docker sessions not yet covered by a DB
426
+ const discoveredOrchestrators = collectOrchestrators(snapshot, hqsToQuery, seenSessionIds);
427
+ for (const s of discoveredOrchestrators) {
428
+ if (s.sessionId)
429
+ seenSessionIds.add(s.sessionId);
430
+ all.push(s);
431
+ }
432
+ // Apply filters
433
+ let filtered = all;
434
+ if (options.hqPathFilter) {
435
+ let normalized;
436
+ try {
437
+ normalized = normalizePath(options.hqPathFilter);
438
+ }
439
+ catch {
440
+ normalized = options.hqPathFilter;
441
+ }
442
+ filtered = filtered.filter(s => {
443
+ if (!s.hqPath)
444
+ return false;
445
+ try {
446
+ return normalizePath(s.hqPath) === normalized;
447
+ }
448
+ catch {
449
+ return s.hqPath === normalized;
450
+ }
451
+ });
452
+ }
453
+ if (options.roleFilter) {
454
+ filtered = filtered.filter(s => s.role === options.roleFilter);
455
+ }
456
+ return filtered;
457
+ }
458
+ /**
459
+ * Group sessions into "current HQ" vs "other locations" buckets.
460
+ *
461
+ * The current HQ is resolved from `process.cwd()` (or an explicit override).
462
+ * Sessions whose hqPath matches the current HQ go into `here`; everything
463
+ * else goes into `elsewhere`.
464
+ */
465
+ export function groupSessionsByHQ(sessions, currentHqOverride) {
466
+ const currentHq = currentHqOverride === null
467
+ ? undefined
468
+ : currentHqOverride ?? findHQRoot(process.cwd()) ?? undefined;
469
+ let normalizedCurrent;
470
+ if (currentHq) {
471
+ try {
472
+ normalizedCurrent = normalizePath(currentHq);
473
+ }
474
+ catch {
475
+ normalizedCurrent = currentHq;
476
+ }
477
+ }
478
+ const here = [];
479
+ const elsewhere = [];
480
+ for (const s of sessions) {
481
+ if (!normalizedCurrent || !s.hqPath) {
482
+ elsewhere.push(s);
483
+ continue;
484
+ }
485
+ let normalizedSession;
486
+ try {
487
+ normalizedSession = normalizePath(s.hqPath);
488
+ }
489
+ catch {
490
+ normalizedSession = s.hqPath;
491
+ }
492
+ if (normalizedSession === normalizedCurrent) {
493
+ here.push(s);
494
+ }
495
+ else {
496
+ elsewhere.push(s);
497
+ }
498
+ }
499
+ return {
500
+ currentHq,
501
+ currentHqName: currentHq ? getHeadquartersNameFromPath(currentHq) : undefined,
502
+ here,
503
+ elsewhere,
504
+ };
505
+ }
506
+ // =============================================================================
507
+ // Pretty rendering helpers (text)
508
+ // =============================================================================
509
+ /** Format a Date into a compact relative duration like "~15m", "~3h", "~2d". */
510
+ export function formatRelativeAge(start, now = new Date()) {
511
+ if (!start)
512
+ return '';
513
+ const diffMs = Math.max(0, now.getTime() - start.getTime());
514
+ const sec = Math.floor(diffMs / 1000);
515
+ if (sec < 60)
516
+ return `~${sec}s`;
517
+ const min = Math.floor(sec / 60);
518
+ if (min < 60)
519
+ return `~${min}m`;
520
+ const hr = Math.floor(min / 60);
521
+ if (hr < 48)
522
+ return `~${hr}h`;
523
+ const day = Math.floor(hr / 24);
524
+ return `~${day}d`;
525
+ }
526
+ /**
527
+ * Group elsewhere sessions by hqPath/hqName for nicer rendering.
528
+ * Sessions without an hqPath are bucketed under "(no HQ)".
529
+ */
530
+ export function bucketElsewhereByHq(elsewhere) {
531
+ const buckets = new Map();
532
+ for (const s of elsewhere) {
533
+ const key = s.hqPath ?? '__none__';
534
+ let bucket = buckets.get(key);
535
+ if (!bucket) {
536
+ bucket = {
537
+ hqPath: s.hqPath ?? null,
538
+ hqName: s.hqName ?? '(no HQ)',
539
+ sessions: [],
540
+ };
541
+ buckets.set(key, bucket);
542
+ }
543
+ bucket.sessions.push(s);
544
+ }
545
+ return Array.from(buckets.values());
546
+ }
547
+ //# sourceMappingURL=renderer.js.map