@stackmemoryai/stackmemory 1.5.7 → 1.5.8

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.
@@ -16,7 +16,9 @@ import { homedir, tmpdir } from "os";
16
16
  import Database from "better-sqlite3";
17
17
  import { logger } from "../../core/monitoring/logger.js";
18
18
  import { Conductor } from "./orchestrator.js";
19
- import { getAgentStatusDir } from "./orchestrator.js";
19
+ import {
20
+ getAgentStatusDir
21
+ } from "./orchestrator.js";
20
22
  function getGlobalStorePath() {
21
23
  const dir = join(homedir(), ".stackmemory", "conductor");
22
24
  if (!existsSync(dir)) {
@@ -73,6 +75,101 @@ function budgetBar(pct, width = 30) {
73
75
  const rst = "\x1B[0m";
74
76
  return `${color}${"\u2588".repeat(filled)}${dim}${"\u2591".repeat(empty)}${rst} ${String(pct).padStart(3)}%`;
75
77
  }
78
+ const c = {
79
+ r: "\x1B[0m",
80
+ // reset
81
+ b: "\x1B[1m",
82
+ // bold
83
+ d: "\x1B[2m",
84
+ // dim
85
+ i: "\x1B[3m",
86
+ // italic
87
+ u: "\x1B[4m",
88
+ // underline
89
+ // Linear-inspired palette
90
+ purple: "\x1B[38;5;141m",
91
+ blue: "\x1B[38;5;75m",
92
+ cyan: "\x1B[38;5;80m",
93
+ green: "\x1B[38;5;114m",
94
+ yellow: "\x1B[38;5;221m",
95
+ orange: "\x1B[38;5;215m",
96
+ red: "\x1B[38;5;203m",
97
+ pink: "\x1B[38;5;176m",
98
+ gray: "\x1B[38;5;245m",
99
+ white: "\x1B[38;5;255m",
100
+ bg: {
101
+ purple: "\x1B[48;5;53m",
102
+ blue: "\x1B[48;5;24m",
103
+ green: "\x1B[48;5;22m",
104
+ red: "\x1B[48;5;52m",
105
+ yellow: "\x1B[48;5;58m",
106
+ gray: "\x1B[48;5;236m"
107
+ }
108
+ };
109
+ const phaseIcon = {
110
+ reading: "\u25D4",
111
+ planning: "\u25D1",
112
+ implementing: "\u25D5",
113
+ testing: "\u25CF",
114
+ committing: "\u2713"
115
+ };
116
+ const phaseColor = {
117
+ reading: c.cyan,
118
+ planning: c.blue,
119
+ implementing: c.yellow,
120
+ testing: c.pink,
121
+ committing: c.green
122
+ };
123
+ function isProcessAlive(pid) {
124
+ try {
125
+ process.kill(pid, 0);
126
+ return true;
127
+ } catch {
128
+ return false;
129
+ }
130
+ }
131
+ function phaseProgress(phase, toolCalls, stale, alive) {
132
+ const basePct = {
133
+ reading: 10,
134
+ planning: 25,
135
+ implementing: 50,
136
+ testing: 75,
137
+ committing: 90
138
+ };
139
+ let pct = basePct[phase] || 0;
140
+ if (phase === "implementing") {
141
+ pct += Math.min(Math.floor(toolCalls / 80 * 25), 25);
142
+ }
143
+ if (phase === "committing") {
144
+ pct = 90 + Math.min(Math.floor(toolCalls / 60 * 10), 9);
145
+ }
146
+ const labels = {
147
+ reading: "Reading",
148
+ planning: "Planning",
149
+ implementing: "Implementing",
150
+ testing: "Testing",
151
+ committing: "Committing"
152
+ };
153
+ let label = labels[phase] || phase;
154
+ let color = phaseColor[phase] || "";
155
+ let icon = phaseIcon[phase] || "\u25CB";
156
+ if (!alive) {
157
+ label = "Dead";
158
+ color = c.red;
159
+ icon = "\u2717";
160
+ } else if (stale) {
161
+ label = "Stalled";
162
+ color = c.orange;
163
+ icon = "\u23F8";
164
+ }
165
+ return { icon, color, pct, label };
166
+ }
167
+ function progressBar(pct, width) {
168
+ const filled = Math.min(Math.round(pct / 100 * width), width);
169
+ const empty = width - filled;
170
+ const col = pct >= 90 ? c.green : pct >= 50 ? c.yellow : c.cyan;
171
+ return `${col}${"\u2501".repeat(filled)}${c.d}${"\u254C".repeat(empty)}${c.r}`;
172
+ }
76
173
  function fmtMinutes(m) {
77
174
  if (m < 0) return "N/A";
78
175
  if (m >= 60) return `${Math.floor(m / 60)}h ${m % 60}m`;
@@ -90,23 +187,23 @@ function printUsageSummary(u) {
90
187
  const mins20x = u.minutesRemaining20x ?? -1;
91
188
  const cacheHitRate = u.cacheHitRate || 0;
92
189
  const b = "\x1B[1m";
93
- const d = "\x1B[2m";
190
+ const d2 = "\x1B[2m";
94
191
  const w = "\x1B[37m";
95
- const r = "\x1B[0m";
96
- console.log(`${b}Token Usage${r}`);
192
+ const r2 = "\x1B[0m";
193
+ console.log(`${b}Token Usage${r2}`);
97
194
  console.log(
98
- ` Input ${w}${fmtTokens(inputTokens)}${r} ${d}|${r} Output ${w}${fmtTokens(outputTokens)}${r} ${d}|${r} Total ${w}${fmtTokens(totalTokens)}${r}`
195
+ ` Input ${w}${fmtTokens(inputTokens)}${r2} ${d2}|${r2} Output ${w}${fmtTokens(outputTokens)}${r2} ${d2}|${r2} Total ${w}${fmtTokens(totalTokens)}${r2}`
99
196
  );
100
197
  console.log(
101
- ` Rate ${w}${fmtTokens(tokensPerMin)}/min${r} ${d}|${r} Messages ${w}${estMessages}${r} ${d}|${r} Cache hit ${w}${cacheHitRate}%${r}`
198
+ ` Rate ${w}${fmtTokens(tokensPerMin)}/min${r2} ${d2}|${r2} Messages ${w}${estMessages}${r2} ${d2}|${r2} Cache hit ${w}${cacheHitRate}%${r2}`
102
199
  );
103
200
  console.log("");
104
- console.log(`${b}Budget (Max plan, 5h window)${r}`);
201
+ console.log(`${b}Budget (Max plan, 5h window)${r2}`);
105
202
  console.log(
106
- ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d}~${fmtMinutes(mins5x)} left${r}`
203
+ ` 5x (225 msgs) ${budgetBar(budgetPct5x)} ${d2}~${fmtMinutes(mins5x)} left${r2}`
107
204
  );
108
205
  console.log(
109
- ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d}~${fmtMinutes(mins20x)} left${r}`
206
+ ` 20x (900 msgs) ${budgetBar(budgetPct20x)} ${d2}~${fmtMinutes(mins20x)} left${r2}`
110
207
  );
111
208
  }
112
209
  function createConductorCommands() {
@@ -226,14 +323,14 @@ function createConductorCommands() {
226
323
  const risks = anchors.filter((a) => a.type === "RISK");
227
324
  if (decisions.length > 0) {
228
325
  lines.push("", "### Decisions");
229
- for (const d of decisions.slice(0, 10)) {
230
- lines.push(`- ${d.text}`);
326
+ for (const d2 of decisions.slice(0, 10)) {
327
+ lines.push(`- ${d2.text}`);
231
328
  }
232
329
  }
233
330
  if (risks.length > 0) {
234
331
  lines.push("", "### Risks");
235
- for (const r of risks.slice(0, 5)) {
236
- lines.push(`- ${r.text}`);
332
+ for (const r2 of risks.slice(0, 5)) {
333
+ lines.push(`- ${r2.text}`);
237
334
  }
238
335
  }
239
336
  } catch {
@@ -317,13 +414,13 @@ function createConductorCommands() {
317
414
  }
318
415
  console.log(`Found ${results.length} result(s) for "${query}":
319
416
  `);
320
- for (const r of results) {
321
- const date = new Date(r.captured_at * 1e3).toISOString().slice(0, 16);
417
+ for (const r2 of results) {
418
+ const date = new Date(r2.captured_at * 1e3).toISOString().slice(0, 16);
322
419
  console.log(
323
- ` ${r.issue_id} [${r.context_type}] attempt ${r.attempt} (${date})`
420
+ ` ${r2.issue_id} [${r2.context_type}] attempt ${r2.attempt} (${date})`
324
421
  );
325
- if (r.summary) {
326
- const snippet = r.summary.slice(0, 120).replace(/\n/g, " ");
422
+ if (r2.summary) {
423
+ const snippet = r2.summary.slice(0, 120).replace(/\n/g, " ");
327
424
  console.log(` ${snippet}`);
328
425
  }
329
426
  }
@@ -354,13 +451,162 @@ function createConductorCommands() {
354
451
  statuses.sort(
355
452
  (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
356
453
  );
357
- const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
358
- console.log(header);
454
+ const active = statuses.filter((s) => isProcessAlive(s.pid));
455
+ const stalled = statuses.filter(
456
+ (s) => isProcessAlive(s.pid) && Date.now() - new Date(s.lastUpdate).getTime() > 5 * 60 * 1e3
457
+ );
458
+ const dead = statuses.filter((s) => !isProcessAlive(s.pid));
459
+ const healthy = active.length - stalled.length;
460
+ const parts = [];
461
+ if (healthy > 0) parts.push(`${c.green}\u25CF ${healthy}${c.r}`);
462
+ if (stalled.length > 0)
463
+ parts.push(`${c.orange}\u23F8 ${stalled.length}${c.r}`);
464
+ if (dead.length > 0) parts.push(`${c.red}\u2717 ${dead.length}${c.r}`);
465
+ console.log(`
466
+ ${c.b}${c.white}Conductor${c.r} ${parts.join(" ")}
467
+ `);
468
+ const cols = (process.stdout.columns || 80) >= 90 ? 2 : 1;
469
+ const rows = [];
359
470
  for (const s of statuses) {
360
471
  const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
361
- const lastUpdate = formatElapsed(elapsed);
362
- const line = `${s.issue.padEnd(12)}${s.phase.padEnd(16)}${String(s.toolCalls).padStart(7)}${String(s.filesModified).padStart(7)}${String(s.tokensUsed).padStart(9)} ${lastUpdate}`;
363
- console.log(line);
472
+ const staleFlag = elapsed > 5 * 60 * 1e3;
473
+ const alive = isProcessAlive(s.pid);
474
+ const { icon, color, pct, label } = phaseProgress(
475
+ s.phase,
476
+ s.toolCalls,
477
+ staleFlag,
478
+ alive
479
+ );
480
+ const bar = progressBar(pct, 8);
481
+ const timeColor = !alive ? c.red : staleFlag ? c.orange : c.gray;
482
+ const cell = [
483
+ `${color}${icon}${c.r} ${c.b}${s.issue}${c.r} ${color}${label}${c.r}`,
484
+ ` ${bar} ${c.d}${pct}%${c.r} ${c.gray}${s.toolCalls}t ${s.filesModified}f${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
485
+ ];
486
+ rows.push(cell);
487
+ }
488
+ if (cols === 2) {
489
+ for (let i = 0; i < rows.length; i += 2) {
490
+ const left = rows[i];
491
+ const right = rows[i + 1];
492
+ const pad = 42;
493
+ if (right) {
494
+ console.log(
495
+ ` ${left[0].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[0]}`
496
+ );
497
+ console.log(
498
+ ` ${left[1].padEnd(pad + 30)}${c.gray}\u2502${c.r} ${right[1]}`
499
+ );
500
+ } else {
501
+ console.log(` ${left[0]}`);
502
+ console.log(` ${left[1]}`);
503
+ }
504
+ if (i + 2 < rows.length) {
505
+ console.log(` ${c.gray}${"\u254C".repeat(38)}\u253C${"\u254C".repeat(38)}${c.r}`);
506
+ }
507
+ }
508
+ } else {
509
+ for (let i = 0; i < rows.length; i++) {
510
+ console.log(` ${rows[i][0]}`);
511
+ console.log(` ${rows[i][1]}`);
512
+ if (i < rows.length - 1) {
513
+ console.log(` ${c.gray}${"\u254C".repeat(38)}${c.r}`);
514
+ }
515
+ }
516
+ }
517
+ console.log("");
518
+ });
519
+ cmd.command("finalize").description("Clean up completed/dead agents that conductor missed").option("--dry-run", "Show what would be done without doing it", false).action(async (options) => {
520
+ const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
521
+ if (!existsSync(agentsDir)) {
522
+ console.log("No agent status files found");
523
+ return;
524
+ }
525
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
526
+ const statuses = [];
527
+ for (const entry of entries) {
528
+ if (!entry.isDirectory()) continue;
529
+ const statusPath = join(agentsDir, entry.name, "status.json");
530
+ if (!existsSync(statusPath)) continue;
531
+ try {
532
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
533
+ statuses.push({ ...data, dir: entry.name });
534
+ } catch {
535
+ }
536
+ }
537
+ const needsFinalize = statuses.filter((s) => {
538
+ const alive = isProcessAlive(s.pid);
539
+ const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
540
+ return !alive || elapsed > 60 * 60 * 1e3;
541
+ });
542
+ if (needsFinalize.length === 0) {
543
+ console.log(
544
+ `${c.green}All agents are healthy \u2014 nothing to finalize.${c.r}`
545
+ );
546
+ return;
547
+ }
548
+ console.log(
549
+ `
550
+ ${c.b}Finalizing ${needsFinalize.length} agent(s)${c.r}
551
+ `
552
+ );
553
+ for (const s of needsFinalize) {
554
+ const alive = isProcessAlive(s.pid);
555
+ const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
556
+ const elapsedStr = formatElapsed(elapsed).replace(" ago", "");
557
+ let hasCommits = false;
558
+ if (s.workspacePath && existsSync(s.workspacePath)) {
559
+ try {
560
+ const log = execSync("git log origin/main..HEAD --oneline", {
561
+ cwd: s.workspacePath,
562
+ encoding: "utf-8",
563
+ timeout: 1e4
564
+ });
565
+ hasCommits = log.trim().length > 0;
566
+ } catch {
567
+ }
568
+ }
569
+ const statusIcon = !alive ? `${c.red}\u2717 dead${c.r}` : `${c.orange}\u23F8 stalled ${elapsedStr}${c.r}`;
570
+ const commitStatus = hasCommits ? `${c.green}has commits \u2192 In Review${c.r}` : `${c.gray}no commits \u2192 mark failed${c.r}`;
571
+ console.log(` ${c.b}${s.issue}${c.r} ${statusIcon} ${commitStatus}`);
572
+ if (options.dryRun) continue;
573
+ if (alive) {
574
+ try {
575
+ process.kill(s.pid, "SIGTERM");
576
+ console.log(` ${c.gray}Sent SIGTERM to pid ${s.pid}${c.r}`);
577
+ } catch {
578
+ }
579
+ }
580
+ const statusPath = join(agentsDir, s.dir, "status.json");
581
+ try {
582
+ const updated = { ...s };
583
+ delete updated["dir"];
584
+ writeFileSync(
585
+ statusPath,
586
+ JSON.stringify(
587
+ { ...updated, lastUpdate: (/* @__PURE__ */ new Date()).toISOString() },
588
+ null,
589
+ 2
590
+ )
591
+ );
592
+ } catch {
593
+ }
594
+ if (hasCommits) {
595
+ console.log(
596
+ ` ${c.cyan}\u2192 Move ${s.issue} to "In Review" in Linear${c.r}`
597
+ );
598
+ }
599
+ }
600
+ if (options.dryRun) {
601
+ console.log(
602
+ `
603
+ ${c.d}Dry run \u2014 no changes made. Remove --dry-run to execute.${c.r}`
604
+ );
605
+ } else {
606
+ console.log(
607
+ `
608
+ ${c.green}Done.${c.r} Run ${c.cyan}conductor status${c.r} to verify.`
609
+ );
364
610
  }
365
611
  });
366
612
  cmd.command("logs").description("Tail agent output log").argument("<issue-id>", "Issue identifier (e.g., STA-499)").option("-f, --follow", "Follow the log (tail -f)", false).option("-n, --lines <n>", "Number of lines to show", "50").action(async (issueId, options) => {
@@ -474,13 +720,6 @@ function createConductorCommands() {
474
720
  let paused = false;
475
721
  let refreshInterval = interval;
476
722
  let phaseFilter = options.phase || null;
477
- const b = "\x1B[1m";
478
- const d = "\x1B[2m";
479
- const cyan = "\x1B[36m";
480
- const green = "\x1B[32m";
481
- const yellow = "\x1B[33m";
482
- const red = "\x1B[31m";
483
- const r = "\x1B[0m";
484
723
  function readStatuses() {
485
724
  const agentsDir = join(
486
725
  homedir(),
@@ -501,7 +740,7 @@ function createConductorCommands() {
501
740
  }
502
741
  }
503
742
  statuses.sort(
504
- (a, b2) => new Date(b2.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
743
+ (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
505
744
  );
506
745
  if (phaseFilter) {
507
746
  return statuses.filter((s) => s.phase === phaseFilter);
@@ -514,12 +753,19 @@ function createConductorCommands() {
514
753
  console.log(` No active agents${filterNote}`);
515
754
  return;
516
755
  }
517
- const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
518
- console.log(header);
519
756
  for (const s of statuses) {
520
757
  const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
521
- const line = `${s.issue.padEnd(12)}${s.phase.padEnd(16)}${String(s.toolCalls).padStart(7)}${String(s.filesModified).padStart(7)}${String(s.tokensUsed).padStart(9)} ${formatElapsed(elapsed)}`;
522
- console.log(line);
758
+ const stale = elapsed > 5 * 60 * 1e3;
759
+ const alive = isProcessAlive(s.pid);
760
+ const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
761
+ const bar = progressBar(prog.pct, 10);
762
+ const timeColor = !alive ? c.red : stale ? c.orange : c.gray;
763
+ console.log(
764
+ ` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${timeColor}${formatElapsed(elapsed)}${c.r}`
765
+ );
766
+ console.log(
767
+ ` ${bar} ${c.d}${prog.pct}%${c.r} ${c.gray}${s.toolCalls} tools ${s.filesModified} files ${fmtTokens(s.tokensUsed)} tok${c.r}`
768
+ );
523
769
  }
524
770
  }
525
771
  function getWorktreeFiles(workspacePath) {
@@ -543,23 +789,25 @@ function createConductorCommands() {
543
789
  }
544
790
  for (const s of statuses) {
545
791
  const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
546
- const phaseColor = s.phase === "committing" ? green : s.phase === "testing" ? yellow : s.phase === "implementing" ? cyan : "";
792
+ const stale = elapsed > 5 * 60 * 1e3;
793
+ const alive = isProcessAlive(s.pid);
794
+ const prog = phaseProgress(s.phase, s.toolCalls, stale, alive);
547
795
  console.log(
548
- `${b}${s.issue}${r} ${phaseColor}${s.phase}${r} ${d}${formatElapsed(elapsed)}${r}`
796
+ ` ${prog.color}${prog.icon}${c.r} ${c.b}${s.issue}${c.r} ${prog.color}${prog.label}${c.r} ${c.gray}${formatElapsed(elapsed)}${c.r}`
549
797
  );
550
798
  const files = getWorktreeFiles(s.workspacePath || "");
551
799
  if (files.length === 0) {
552
- console.log(` ${d}(no file changes detected)${r}`);
800
+ console.log(` ${c.d}(no file changes)${c.r}`);
553
801
  } else {
554
802
  for (const f of files) {
555
803
  const status = f.substring(0, 2);
556
804
  const path = f.substring(3);
557
- let color = "";
558
- if (status.includes("M")) color = yellow;
805
+ let col = "";
806
+ if (status.includes("M")) col = c.yellow;
559
807
  else if (status.includes("A") || status.includes("?"))
560
- color = green;
561
- else if (status.includes("D")) color = red;
562
- console.log(` ${color}${status}${r} ${path}`);
808
+ col = c.green;
809
+ else if (status.includes("D")) col = c.red;
810
+ console.log(` ${col}${status}${c.r} ${path}`);
563
811
  }
564
812
  }
565
813
  console.log("");
@@ -575,23 +823,20 @@ function createConductorCommands() {
575
823
  const pauseTag = paused ? " [PAUSED]" : "";
576
824
  const intervalSec = Math.round(refreshInterval / 1e3);
577
825
  console.log(
578
- `${b}\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550${r}`
579
- );
580
- console.log(
581
- `${b} Conductor Monitor${r} ${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}`
826
+ `${c.purple}${c.b} \u2501\u2501\u2501 Conductor Monitor \u2501\u2501\u2501${c.r} ${c.gray}${(/* @__PURE__ */ new Date()).toLocaleTimeString()}${pauseTag}${c.r}`
582
827
  );
583
828
  console.log(
584
- ` Mode: ${cyan}${currentMode}${r} | Refresh: ${intervalSec}s`
829
+ ` ${c.gray}Mode:${c.r} ${c.cyan}${currentMode}${c.r} ${c.gray}\u2502${c.r} ${c.gray}Refresh:${c.r} ${intervalSec}s`
585
830
  );
586
- const filterNote = phaseFilter ? ` Filter: ${cyan}${phaseFilter}${r}` : "";
831
+ const filterNote = phaseFilter ? ` ${c.gray}Filter:${c.r} ${c.cyan}${phaseFilter}${c.r}` : "";
587
832
  if (interactive) {
588
833
  console.log(
589
- ` ${d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${r}`
834
+ ` ${c.d}[s]tatus [u]sage [f]iles [d]ashboard [j]son [l]ogs [r]efresh [p]ause [1-5]phase [0]clear [+/-] [q]uit${c.r}`
590
835
  );
591
836
  }
592
837
  if (filterNote) console.log(filterNote);
593
838
  console.log(
594
- `${b}\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550\u2550${r}`
839
+ `${c.gray} \u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500\u2500${c.r}`
595
840
  );
596
841
  console.log("");
597
842
  const statuses = readStatuses();
@@ -6,6 +6,7 @@ import { spawn, execSync } from "child_process";
6
6
  import {
7
7
  existsSync,
8
8
  mkdirSync,
9
+ readFileSync,
9
10
  rmSync,
10
11
  writeFileSync,
11
12
  readdirSync,
@@ -452,6 +453,7 @@ class Conductor {
452
453
  logger.info("Rate limit backoff expired, resuming dispatch");
453
454
  console.log("[rate-limit] Backoff expired, resuming dispatch");
454
455
  }
456
+ await this.checkStaleAgents();
455
457
  await this.reconcile();
456
458
  const available = this.config.maxConcurrent - this.running.size;
457
459
  if (available <= 0) {
@@ -949,6 +951,26 @@ class Conductor {
949
951
  return "claude";
950
952
  }
951
953
  // ── Agent Execution ──
954
+ /**
955
+ * Build environment variables for an agent process.
956
+ * Injects PORTLESS_URL if portless is available, giving each worktree
957
+ * a stable named localhost URL for service discovery.
958
+ */
959
+ buildAgentEnv(issue, run) {
960
+ const env = { ...process.env };
961
+ delete env["CLAUDECODE"];
962
+ delete env["ANTHROPIC_API_KEY"];
963
+ const wsKey = this.sanitizeIdentifier(issue.identifier);
964
+ return {
965
+ ...env,
966
+ SYMPHONY_WORKSPACE_DIR: run.workspacePath,
967
+ SYMPHONY_ISSUE_ID: issue.id,
968
+ SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
969
+ SYMPHONY_ATTEMPT: String(run.attempt),
970
+ PORTLESS_URL: `http://${wsKey}.localhost:1355`,
971
+ PORTLESS_NAME: wsKey
972
+ };
973
+ }
952
974
  async runAgent(issue, run) {
953
975
  if (this.config.agentMode === "cli") {
954
976
  try {
@@ -995,18 +1017,7 @@ class Conductor {
995
1017
  ],
996
1018
  {
997
1019
  cwd: run.workspacePath,
998
- env: (() => {
999
- const env = { ...process.env };
1000
- delete env.CLAUDECODE;
1001
- delete env.ANTHROPIC_API_KEY;
1002
- return {
1003
- ...env,
1004
- SYMPHONY_WORKSPACE_DIR: run.workspacePath,
1005
- SYMPHONY_ISSUE_ID: issue.id,
1006
- SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
1007
- SYMPHONY_ATTEMPT: String(run.attempt)
1008
- };
1009
- })(),
1020
+ env: this.buildAgentEnv(issue, run),
1010
1021
  stdio: ["pipe", "pipe", "pipe"]
1011
1022
  }
1012
1023
  );
@@ -1114,18 +1125,9 @@ class Conductor {
1114
1125
  runAgentAdapter(issue, run) {
1115
1126
  return new Promise((resolve, reject) => {
1116
1127
  const prompt = this.buildPrompt(issue, run.attempt);
1117
- const env = { ...process.env };
1118
- delete env.CLAUDECODE;
1119
- delete env.ANTHROPIC_API_KEY;
1120
1128
  const proc = spawn("node", [this.config.appServerPath], {
1121
1129
  cwd: run.workspacePath,
1122
- env: {
1123
- ...env,
1124
- SYMPHONY_WORKSPACE_DIR: run.workspacePath,
1125
- SYMPHONY_ISSUE_ID: issue.id,
1126
- SYMPHONY_ISSUE_IDENTIFIER: issue.identifier,
1127
- SYMPHONY_ATTEMPT: String(run.attempt)
1128
- },
1130
+ env: this.buildAgentEnv(issue, run),
1129
1131
  stdio: ["pipe", "pipe", "pipe"]
1130
1132
  });
1131
1133
  run.process = proc;
@@ -1405,6 +1407,117 @@ class Conductor {
1405
1407
  }
1406
1408
  }
1407
1409
  // ── Reconciliation ──
1410
+ /**
1411
+ * Check for stale agents — no status update in 1 hour.
1412
+ * If the process is dead, clean up. If alive but stuck, kill and free the slot.
1413
+ */
1414
+ async checkStaleAgents() {
1415
+ if (this.running.size === 0) return;
1416
+ const staleThresholdMs = 60 * 60 * 1e3;
1417
+ const now = Date.now();
1418
+ for (const [issueId, run] of this.running) {
1419
+ const agentDir = getAgentStatusDir(run.issue.identifier);
1420
+ const statusPath = join(agentDir, "status.json");
1421
+ let lastUpdate = run.startedAt;
1422
+ try {
1423
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
1424
+ lastUpdate = new Date(data.lastUpdate).getTime();
1425
+ } catch {
1426
+ }
1427
+ const elapsed = now - lastUpdate;
1428
+ if (elapsed < staleThresholdMs) continue;
1429
+ const elapsedMin = Math.round(elapsed / 6e4);
1430
+ const pid = run.process?.pid;
1431
+ let alive = false;
1432
+ if (pid) {
1433
+ try {
1434
+ process.kill(pid, 0);
1435
+ alive = true;
1436
+ } catch {
1437
+ }
1438
+ }
1439
+ if (!alive) {
1440
+ logger.warn("Stale agent process is dead, cleaning up", {
1441
+ identifier: run.issue.identifier,
1442
+ pid,
1443
+ staleMins: elapsedMin
1444
+ });
1445
+ console.log(
1446
+ `[${run.issue.identifier}] Agent dead after ${elapsedMin}m \u2014 finalizing`
1447
+ );
1448
+ await this.finalizeStaleAgent(run);
1449
+ this.running.delete(issueId);
1450
+ } else {
1451
+ logger.warn("Stale agent still alive but stuck, killing", {
1452
+ identifier: run.issue.identifier,
1453
+ pid,
1454
+ staleMins: elapsedMin
1455
+ });
1456
+ console.log(
1457
+ `[${run.issue.identifier}] Agent stuck for ${elapsedMin}m \u2014 killing pid ${pid}`
1458
+ );
1459
+ run.process?.kill("SIGTERM");
1460
+ await new Promise((resolve) => setTimeout(resolve, 1e4));
1461
+ if (run.process && !run.process.killed) {
1462
+ run.process.kill("SIGKILL");
1463
+ }
1464
+ await this.finalizeStaleAgent(run);
1465
+ this.running.delete(issueId);
1466
+ }
1467
+ }
1468
+ }
1469
+ /**
1470
+ * Finalize an agent that completed or died without proper cleanup.
1471
+ * Checks for commits, transitions Linear issue, takes snapshot.
1472
+ */
1473
+ async finalizeStaleAgent(run) {
1474
+ const wsPath = run.workspacePath;
1475
+ if (!wsPath || !existsSync(wsPath)) {
1476
+ this.completed.add(run.issue.id);
1477
+ return;
1478
+ }
1479
+ let hasCommits = false;
1480
+ try {
1481
+ const log = execSync(
1482
+ `git log origin/${this.config.baseBranch}..HEAD --oneline`,
1483
+ { cwd: wsPath, encoding: "utf-8", timeout: 1e4 }
1484
+ );
1485
+ hasCommits = log.trim().length > 0;
1486
+ } catch {
1487
+ }
1488
+ if (hasCommits) {
1489
+ logger.info("Stale agent had commits, transitioning to In Review", {
1490
+ identifier: run.issue.identifier
1491
+ });
1492
+ console.log(
1493
+ `[${run.issue.identifier}] Found commits \u2014 moving to In Review`
1494
+ );
1495
+ this.takeSnapshot(wsPath, run.issue);
1496
+ await this.transitionIssue(run.issue, this.config.inReviewState).catch(
1497
+ (err) => {
1498
+ logger.error("Failed to transition stale agent issue", {
1499
+ identifier: run.issue.identifier,
1500
+ error: err.message
1501
+ });
1502
+ }
1503
+ );
1504
+ this.completeCount++;
1505
+ } else {
1506
+ logger.info("Stale agent had no commits, marking as failed", {
1507
+ identifier: run.issue.identifier
1508
+ });
1509
+ console.log(
1510
+ `[${run.issue.identifier}] No commits found \u2014 marking failed`
1511
+ );
1512
+ this.failCount++;
1513
+ }
1514
+ this.writeAgentStatus(run.issue.identifier, {
1515
+ ...run,
1516
+ status: hasCommits ? "completed" : "failed",
1517
+ phase: hasCommits ? "committing" : run.phase
1518
+ });
1519
+ this.completed.add(run.issue.id);
1520
+ }
1408
1521
  async reconcile() {
1409
1522
  if (!this.client || this.running.size === 0) return;
1410
1523
  for (const [issueId, run] of this.running) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.7",
3
+ "version": "1.5.8",
4
4
  "description": "Lossless, project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, conductor orchestrator, loop/watch monitoring, snapshot capture, pre-flight overlap checks, Claude/Codex/OpenCode wrappers, Linear sync, and automatic hooks.",
5
5
  "engines": {
6
6
  "node": ">=20.0.0",