@stackmemoryai/stackmemory 1.5.1 → 1.5.3

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/README.md CHANGED
@@ -24,7 +24,8 @@ StackMemory is a **production-ready memory runtime** for AI coding tools that pr
24
24
  - **Automatic hooks** for task tracking, Linear sync, and spec progress
25
25
  - **Snapshot capture** — post-run context snapshots for session handoff (`snapshot save`)
26
26
  - **Pre-flight overlap check** — predict file conflicts before parallel task dispatch (`preflight`)
27
- - **Conductor orchestrator** — polls Linear, creates worktrees, spawns agents with bounded concurrency
27
+ - **Conductor orchestrator** — polls Linear, creates worktrees, spawns agents with bounded concurrency and auto team detection
28
+ - **Loop/Watch command** — poll any shell command until a condition is met (monitor CI, deploys, logs)
28
29
  - **Memory monitor daemon** with automatic capture/clear on RAM pressure
29
30
  - **Auto-save service** for periodic context persistence
30
31
  - **Comprehensive test coverage** across all core modules
@@ -68,6 +69,8 @@ Tools forget decisions and constraints between sessions. StackMemory makes conte
68
69
  - **Safe branches**: worktree isolation with `--worktree` or `-w`
69
70
  - **Snapshot**: post-run context capture — records what changed, commits, and decisions (`stackmemory snapshot`)
70
71
  - **Pre-flight check**: file overlap prediction before parallel task dispatch (`stackmemory preflight`)
72
+ - **Loop/Watch**: poll shell commands until conditions are met — monitor CI, deploys, logs (`stackmemory loop`)
73
+ - **Conductor**: autonomous orchestrator — polls Linear, creates worktrees, spawns agents with auto team detection
71
74
  - **Persistent context**: frames, anchors, decisions, retrieval
72
75
  - **Integrations**: Linear (API key + OAuth), DiffMem, Browser MCP, log-mcp (log analysis)
73
76
 
@@ -389,6 +392,26 @@ stackmemory pf "auth work" "db migration" -f "task1:src/auth.ts;task2:src/db.ts"
389
392
  stackmemory pf "task A" "task B" "task C" --json
390
393
  ```
391
394
 
395
+ ### Loop / Watch (`loop` / `watch`)
396
+
397
+ Poll a shell command until a condition is met. Useful for monitoring CI runs, deploy logs, inboxes, or any external state.
398
+
399
+ ```bash
400
+ # Monitor GitHub Actions until complete
401
+ stackmemory loop "gh run view <id> --json status -q .status" --until "completed"
402
+
403
+ # Watch deploy logs for success
404
+ stackmemory watch "railway logs --latest" --until "deployed" -i 5s
405
+
406
+ # Wait for health check to pass
407
+ stackmemory loop "curl -s http://localhost:3000/health" --until "ok" -i 3s -t 5m
408
+
409
+ # JSON output for programmatic use
410
+ stackmemory loop "gh pr checks 42 --json state -q '.[0].state'" --until "SUCCESS" --json
411
+ ```
412
+
413
+ Options: `--until`, `--until-not`, `--until-empty`, `--until-non-empty`, `--until-exit`, `-i/--interval` (default 10s), `-t/--timeout` (default 30m), `--json`, `-q/--quiet`
414
+
392
415
  ---
393
416
 
394
417
  ## Documentation
@@ -8,7 +8,7 @@ import Table from "cli-table3";
8
8
  import { SessionManager } from "../../core/session/session-manager.js";
9
9
  import Database from "better-sqlite3";
10
10
  import { join } from "path";
11
- import { existsSync } from "fs";
11
+ import { existsSync, readFileSync } from "fs";
12
12
  import { getModelTokenLimit } from "../../core/models/model-router.js";
13
13
  const dashboardCommand = {
14
14
  command: "dashboard",
@@ -150,6 +150,52 @@ const dashboardCommand = {
150
150
  console.log(chalk.yellow.bold("\u{1F4BE} Context Usage"));
151
151
  console.log(`${usageBar} ${contextUsage}%`);
152
152
  console.log();
153
+ const conductorStatusPath = join(
154
+ projectRoot,
155
+ ".stackmemory",
156
+ "conductor-status.json"
157
+ );
158
+ if (existsSync(conductorStatusPath)) {
159
+ try {
160
+ const raw = readFileSync(conductorStatusPath, "utf-8");
161
+ const conductorStatus = JSON.parse(raw);
162
+ const stale = Date.now() - conductorStatus.updatedAt > 12e4;
163
+ const header = stale ? chalk.gray.bold("\u2699 Conductor (stale)") : chalk.yellow.bold("\u2699 Conductor");
164
+ console.log(header);
165
+ if (conductorStatus.running.length > 0) {
166
+ const conductorTable = new Table({
167
+ head: [
168
+ chalk.white("Issue"),
169
+ chalk.white("Status"),
170
+ chalk.white("Title"),
171
+ chalk.white("Runtime")
172
+ ],
173
+ style: { head: [], border: [] },
174
+ colWidths: [12, 14, 36, 10]
175
+ });
176
+ conductorStatus.running.forEach((r) => {
177
+ const mins = Math.round(r.runtime / 6e4);
178
+ const statusColor = r.status === "running" ? chalk.green : r.status === "completed" ? chalk.cyan : chalk.red;
179
+ conductorTable.push([
180
+ r.identifier,
181
+ statusColor(r.status),
182
+ r.title.slice(0, 34),
183
+ `${mins}m`
184
+ ]);
185
+ });
186
+ console.log(conductorTable.toString());
187
+ } else {
188
+ console.log(chalk.gray(" No agents running"));
189
+ }
190
+ console.log(
191
+ chalk.gray(
192
+ ` Completed: ${conductorStatus.completed} Failed: ${conductorStatus.failed} Max: ${conductorStatus.maxConcurrent}`
193
+ )
194
+ );
195
+ console.log();
196
+ } catch {
197
+ }
198
+ }
153
199
  db.close();
154
200
  if (argv.watch) {
155
201
  console.log(
@@ -3,13 +3,20 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { Command } from "commander";
6
- import { execSync } from "child_process";
7
- import { existsSync, mkdirSync, writeFileSync } from "fs";
6
+ import { execSync, spawn as cpSpawn } from "child_process";
7
+ import {
8
+ existsSync,
9
+ mkdirSync,
10
+ writeFileSync,
11
+ readFileSync,
12
+ readdirSync
13
+ } from "fs";
8
14
  import { join } from "path";
9
15
  import { homedir, tmpdir } from "os";
10
16
  import Database from "better-sqlite3";
11
17
  import { logger } from "../../core/monitoring/logger.js";
12
18
  import { Conductor } from "./orchestrator.js";
19
+ import { getAgentStatusDir } from "./orchestrator.js";
13
20
  function getGlobalStorePath() {
14
21
  const dir = join(homedir(), ".stackmemory", "conductor");
15
22
  if (!existsSync(dir)) {
@@ -43,6 +50,16 @@ function getGlobalDb() {
43
50
  `);
44
51
  return db;
45
52
  }
53
+ function formatElapsed(ms) {
54
+ const seconds = Math.floor(ms / 1e3);
55
+ if (seconds < 60) return `${seconds}s ago`;
56
+ const minutes = Math.floor(seconds / 60);
57
+ if (minutes < 60) return `${minutes}m ago`;
58
+ const hours = Math.floor(minutes / 60);
59
+ if (hours < 24) return `${hours}h ago`;
60
+ const days = Math.floor(hours / 24);
61
+ return `${days}d ago`;
62
+ }
46
63
  function createConductorCommands() {
47
64
  const cmd = new Command("conductor");
48
65
  cmd.description("Conductor \u2014 autonomous agent orchestration via Linear");
@@ -263,6 +280,60 @@ function createConductorCommands() {
263
280
  }
264
281
  globalDb.close();
265
282
  });
283
+ cmd.command("status").description("Show running agent status table").action(async () => {
284
+ const agentsDir = join(homedir(), ".stackmemory", "conductor", "agents");
285
+ if (!existsSync(agentsDir)) {
286
+ console.log("No agent status files found");
287
+ return;
288
+ }
289
+ const entries = readdirSync(agentsDir, { withFileTypes: true });
290
+ const statuses = [];
291
+ for (const entry of entries) {
292
+ if (!entry.isDirectory()) continue;
293
+ const statusPath = join(agentsDir, entry.name, "status.json");
294
+ if (!existsSync(statusPath)) continue;
295
+ try {
296
+ const data = JSON.parse(readFileSync(statusPath, "utf-8"));
297
+ statuses.push(data);
298
+ } catch {
299
+ }
300
+ }
301
+ if (statuses.length === 0) {
302
+ console.log("No agent status files found");
303
+ return;
304
+ }
305
+ statuses.sort(
306
+ (a, b) => new Date(b.lastUpdate).getTime() - new Date(a.lastUpdate).getTime()
307
+ );
308
+ const header = `${"Issue".padEnd(12)}${"Phase".padEnd(16)}${"Tools".padStart(7)}${"Files".padStart(7)}${"Tokens".padStart(9)} Last Update`;
309
+ console.log(header);
310
+ for (const s of statuses) {
311
+ const elapsed = Date.now() - new Date(s.lastUpdate).getTime();
312
+ const lastUpdate = formatElapsed(elapsed);
313
+ 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}`;
314
+ console.log(line);
315
+ }
316
+ });
317
+ 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) => {
318
+ const logPath = join(getAgentStatusDir(issueId), "output.log");
319
+ if (!existsSync(logPath)) {
320
+ console.error(`No log file found for ${issueId} at ${logPath}`);
321
+ return;
322
+ }
323
+ const lines = parseInt(options.lines, 10);
324
+ const args = options.follow ? ["-f", "-n", String(lines), logPath] : ["-n", String(lines), logPath];
325
+ const tail = cpSpawn("tail", args, { stdio: "inherit" });
326
+ await new Promise((resolve) => {
327
+ tail.on("close", () => {
328
+ resolve();
329
+ });
330
+ const forward = () => {
331
+ tail.kill("SIGTERM");
332
+ };
333
+ process.on("SIGINT", forward);
334
+ process.on("SIGTERM", forward);
335
+ });
336
+ });
266
337
  cmd.command("start").description("Start the orchestrator daemon").option("--team <id>", "Linear team ID").option(
267
338
  "--states <states>",
268
339
  "Comma-separated issue states to pick up",
@@ -294,5 +365,6 @@ function createConductorCommands() {
294
365
  return cmd;
295
366
  }
296
367
  export {
297
- createConductorCommands
368
+ createConductorCommands,
369
+ formatElapsed
298
370
  };
@@ -3,10 +3,17 @@ import { dirname as __pathDirname } from 'path';
3
3
  const __filename = __fileURLToPath(import.meta.url);
4
4
  const __dirname = __pathDirname(__filename);
5
5
  import { spawn, execSync } from "child_process";
6
- import { existsSync, mkdirSync, rmSync } from "fs";
6
+ import {
7
+ existsSync,
8
+ mkdirSync,
9
+ rmSync,
10
+ writeFileSync,
11
+ createWriteStream
12
+ } from "fs";
7
13
  import { join, dirname } from "path";
8
- import { tmpdir } from "os";
14
+ import { tmpdir, homedir } from "os";
9
15
  import { fileURLToPath } from "url";
16
+ import { Transform } from "stream";
10
17
  import { logger } from "../../core/monitoring/logger.js";
11
18
  import {
12
19
  LinearClient
@@ -26,6 +33,65 @@ function findPackageRoot() {
26
33
  }
27
34
  return dirname(currentFile);
28
35
  }
36
+ function getAgentStatusDir(issueIdentifier) {
37
+ return join(
38
+ homedir(),
39
+ ".stackmemory",
40
+ "conductor",
41
+ "agents",
42
+ issueIdentifier
43
+ );
44
+ }
45
+ function ensureAgentStatusDir(issueIdentifier) {
46
+ const dir = getAgentStatusDir(issueIdentifier);
47
+ if (!existsSync(dir)) {
48
+ mkdirSync(dir, { recursive: true });
49
+ }
50
+ return dir;
51
+ }
52
+ class TeeTransform extends Transform {
53
+ logStream;
54
+ constructor(logStream) {
55
+ super();
56
+ this.logStream = logStream;
57
+ }
58
+ _transform(chunk, _encoding, callback) {
59
+ this.logStream.write(chunk);
60
+ this.push(chunk);
61
+ callback();
62
+ }
63
+ _flush(callback) {
64
+ this.logStream.end();
65
+ callback();
66
+ }
67
+ }
68
+ function inferPhase(msg) {
69
+ const method = msg.method;
70
+ const params = msg.params;
71
+ if (method === "item/commandExecution/started") {
72
+ const tool = params?.tool || params?.name || "";
73
+ const toolLower = tool.toLowerCase();
74
+ if (toolLower.includes("read") || toolLower.includes("glob") || toolLower.includes("grep") || toolLower.includes("search")) {
75
+ return "reading";
76
+ }
77
+ if (toolLower.includes("todowrite") || toolLower.includes("todo")) {
78
+ return "planning";
79
+ }
80
+ if (toolLower.includes("edit") || toolLower.includes("write") || toolLower.includes("bash")) {
81
+ return "implementing";
82
+ }
83
+ if (toolLower.includes("test")) {
84
+ return "testing";
85
+ }
86
+ }
87
+ if (method === "item/commandExecution/started") {
88
+ const command = params?.command || "";
89
+ if (command.includes("git commit") || command.includes("git add")) {
90
+ return "committing";
91
+ }
92
+ }
93
+ return null;
94
+ }
29
95
  const DEFAULT_CONFIG = {
30
96
  activeStates: ["Todo"],
31
97
  terminalStates: ["Done", "Cancelled", "Canceled", "Closed"],
@@ -105,6 +171,21 @@ class Conductor {
105
171
  mkdirSync(this.config.workspaceRoot, { recursive: true });
106
172
  }
107
173
  this.client = await this.createLinearClient();
174
+ if (!this.config.teamId && this.client) {
175
+ try {
176
+ const team = await this.client.getTeam();
177
+ this.config.teamId = team.id;
178
+ logger.info("Auto-detected Linear team", {
179
+ id: team.id,
180
+ name: team.name,
181
+ key: team.key
182
+ });
183
+ } catch (err) {
184
+ logger.warn("Failed to auto-detect team", {
185
+ error: err.message
186
+ });
187
+ }
188
+ }
108
189
  await this.cacheWorkflowStates();
109
190
  logger.info("Orchestrator started", {
110
191
  activeStates: this.config.activeStates,
@@ -115,6 +196,7 @@ class Conductor {
115
196
  console.log(
116
197
  `Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
117
198
  );
199
+ this.writeStatusFile();
118
200
  const shutdown = () => this.stop();
119
201
  process.on("SIGINT", shutdown);
120
202
  process.on("SIGTERM", shutdown);
@@ -160,6 +242,7 @@ class Conductor {
160
242
  }
161
243
  this.running.clear();
162
244
  this.claimed.clear();
245
+ this.clearStatusFile();
163
246
  console.log(
164
247
  `Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
165
248
  );
@@ -183,6 +266,82 @@ class Conductor {
183
266
  issues
184
267
  };
185
268
  }
269
+ // ── Status File ──
270
+ /**
271
+ * Write current conductor state to .stackmemory/conductor-status.json
272
+ * for consumption by `stackmemory dashboard` and other tools.
273
+ */
274
+ writeStatusFile() {
275
+ const statusDir = join(this.config.repoRoot, ".stackmemory");
276
+ if (!existsSync(statusDir)) return;
277
+ const status = {
278
+ pid: process.pid,
279
+ startedAt: this.startedAt,
280
+ updatedAt: Date.now(),
281
+ running: Array.from(this.running.values()).map((r) => ({
282
+ identifier: r.issue.identifier,
283
+ title: r.issue.title,
284
+ status: r.status,
285
+ attempt: r.attempt,
286
+ startedAt: r.startedAt,
287
+ runtime: Date.now() - r.startedAt
288
+ })),
289
+ queued: Array.from(this.claimed).filter(
290
+ (id) => !this.running.has(id) && !this.completed.has(id)
291
+ ).length,
292
+ completed: this.completeCount,
293
+ failed: this.failCount,
294
+ totalAttempts: this.totalAttempts,
295
+ maxConcurrent: this.config.maxConcurrent,
296
+ stopping: this.stopping
297
+ };
298
+ try {
299
+ writeFileSync(
300
+ join(statusDir, "conductor-status.json"),
301
+ JSON.stringify(status, null, 2)
302
+ );
303
+ } catch {
304
+ }
305
+ }
306
+ clearStatusFile() {
307
+ const statusPath = join(
308
+ this.config.repoRoot,
309
+ ".stackmemory",
310
+ "conductor-status.json"
311
+ );
312
+ try {
313
+ if (existsSync(statusPath)) rmSync(statusPath);
314
+ } catch {
315
+ }
316
+ }
317
+ // ── Agent Status Files ──
318
+ /**
319
+ * Write per-agent status to ~/.stackmemory/conductor/agents/<issue-id>/status.json
320
+ */
321
+ writeAgentStatus(issueIdentifier, run) {
322
+ try {
323
+ const dir = ensureAgentStatusDir(issueIdentifier);
324
+ const status = {
325
+ issue: issueIdentifier,
326
+ pid: run.process?.pid || process.pid,
327
+ started: new Date(run.startedAt).toISOString(),
328
+ lastUpdate: (/* @__PURE__ */ new Date()).toISOString(),
329
+ phase: run.phase,
330
+ filesModified: run.filesModified,
331
+ toolCalls: run.toolCalls,
332
+ tokensUsed: run.tokensUsed
333
+ };
334
+ writeFileSync(join(dir, "status.json"), JSON.stringify(status, null, 2));
335
+ } catch {
336
+ }
337
+ }
338
+ /**
339
+ * Open a log file write stream for an agent.
340
+ */
341
+ openAgentLogStream(issueIdentifier) {
342
+ const dir = ensureAgentStatusDir(issueIdentifier);
343
+ return createWriteStream(join(dir, "output.log"), { flags: "a" });
344
+ }
186
345
  // ── Polling ──
187
346
  async schedulePoll() {
188
347
  while (!this.stopping) {
@@ -195,6 +354,7 @@ class Conductor {
195
354
  } catch (err) {
196
355
  logger.error("Poll cycle failed", { error: err.message });
197
356
  }
357
+ this.writeStatusFile();
198
358
  }
199
359
  }
200
360
  async poll() {
@@ -322,8 +482,13 @@ class Conductor {
322
482
  process: null,
323
483
  attempt: 1,
324
484
  startedAt: Date.now(),
325
- status: "starting"
485
+ status: "starting",
486
+ phase: "reading",
487
+ toolCalls: 0,
488
+ filesModified: 0,
489
+ tokensUsed: 0
326
490
  };
491
+ this.writeAgentStatus(issue.identifier, run);
327
492
  this.running.set(issueId, run);
328
493
  this.totalAttempts++;
329
494
  console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
@@ -332,23 +497,26 @@ class Conductor {
332
497
  run.workspacePath = workspacePath;
333
498
  await this.transitionIssue(issue, this.config.inProgressState);
334
499
  await this.runHook("after-create", workspacePath, issue);
335
- run.status = "running";
336
- await this.runAgent(issue, run);
337
- run.status = "completed";
338
- this.completeCount++;
339
- await this.runHook("after-run", workspacePath, issue, run.attempt);
340
- this.takeSnapshot(workspacePath, issue);
341
- await this.transitionIssue(issue, this.config.inReviewState);
342
- console.log(`[${issue.identifier}] Completed successfully`);
500
+ await this.attemptRun(issue, run);
343
501
  } catch (err) {
344
- run.status = "failed";
345
- run.error = err.message;
346
- logger.error("Issue dispatch failed", {
347
- identifier: issue.identifier,
348
- error: run.error,
349
- attempt: run.attempt
350
- });
351
- if (run.workspacePath) {
502
+ this.failCount++;
503
+ console.log(`[${issue.identifier}] Failed: ${err.message}`);
504
+ } finally {
505
+ this.running.delete(issueId);
506
+ this.writeStatusFile();
507
+ }
508
+ }
509
+ /**
510
+ * Run the agent with retry logic. Throws on final failure.
511
+ */
512
+ async attemptRun(issue, run) {
513
+ const maxAttempts = this.config.maxRetries + 1;
514
+ while (run.attempt <= maxAttempts) {
515
+ try {
516
+ run.status = "running";
517
+ await this.runAgent(issue, run);
518
+ run.status = "completed";
519
+ this.completeCount++;
352
520
  await this.runHook(
353
521
  "after-run",
354
522
  run.workspacePath,
@@ -356,47 +524,44 @@ class Conductor {
356
524
  run.attempt
357
525
  ).catch(() => {
358
526
  });
359
- }
360
- if (run.attempt < this.config.maxRetries + 1) {
527
+ this.takeSnapshot(run.workspacePath, issue);
528
+ await this.transitionIssue(issue, this.config.inReviewState);
361
529
  console.log(
362
- `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
530
+ run.attempt === 1 ? `[${issue.identifier}] Completed successfully` : `[${issue.identifier}] Completed on retry ${run.attempt}`
363
531
  );
364
- run.attempt++;
365
- this.totalAttempts++;
366
- const backoffMs = Math.min(1e3 * Math.pow(2, run.attempt - 1), 3e5);
367
- await new Promise((r) => setTimeout(r, backoffMs));
368
- if (!this.stopping) {
369
- try {
370
- run.status = "running";
371
- await this.runAgent(issue, run);
372
- run.status = "completed";
373
- this.completeCount++;
374
- await this.runHook(
375
- "after-run",
376
- run.workspacePath,
377
- issue,
378
- run.attempt
379
- ).catch(() => {
380
- });
381
- await this.transitionIssue(issue, this.config.inReviewState);
382
- console.log(
383
- `[${issue.identifier}] Completed on retry ${run.attempt}`
384
- );
385
- } catch (retryErr) {
386
- run.status = "failed";
387
- run.error = retryErr.message;
388
- this.failCount++;
389
- console.log(
390
- `[${issue.identifier}] Failed after ${run.attempt} attempts: ${run.error}`
391
- );
392
- }
532
+ return;
533
+ } catch (err) {
534
+ run.status = "failed";
535
+ run.error = err.message;
536
+ logger.error("Agent run failed", {
537
+ identifier: issue.identifier,
538
+ error: run.error,
539
+ attempt: run.attempt
540
+ });
541
+ if (run.workspacePath) {
542
+ await this.runHook(
543
+ "after-run",
544
+ run.workspacePath,
545
+ issue,
546
+ run.attempt
547
+ ).catch(() => {
548
+ });
549
+ }
550
+ if (run.attempt < maxAttempts && !this.stopping) {
551
+ console.log(
552
+ `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
553
+ );
554
+ run.attempt++;
555
+ this.totalAttempts++;
556
+ const backoffMs = Math.min(
557
+ 1e3 * Math.pow(2, run.attempt - 1),
558
+ 3e5
559
+ );
560
+ await new Promise((r) => setTimeout(r, backoffMs));
561
+ } else {
562
+ throw err;
393
563
  }
394
- } else {
395
- this.failCount++;
396
- console.log(`[${issue.identifier}] Failed: ${run.error}`);
397
564
  }
398
- } finally {
399
- this.running.delete(issueId);
400
565
  }
401
566
  }
402
567
  // ── Workspace Management ──
@@ -492,6 +657,11 @@ class Conductor {
492
657
  stdio: ["pipe", "pipe", "pipe"]
493
658
  });
494
659
  run.process = proc;
660
+ const logStream = this.openAgentLogStream(issue.identifier);
661
+ run.logStream = logStream;
662
+ const tee = new TeeTransform(logStream);
663
+ proc.stdout.pipe(tee);
664
+ this.writeAgentStatus(issue.identifier, run);
495
665
  let stderr = "";
496
666
  let turnCompleted = false;
497
667
  const timer = setTimeout(() => {
@@ -510,7 +680,7 @@ class Conductor {
510
680
  proc.stdin.write(JSON.stringify(msg) + "\n");
511
681
  };
512
682
  let lineBuffer = "";
513
- proc.stdout.on("data", (chunk) => {
683
+ tee.on("data", (chunk) => {
514
684
  lineBuffer += chunk.toString();
515
685
  const lines = lineBuffer.split("\n");
516
686
  lineBuffer = lines.pop() || "";
@@ -519,11 +689,33 @@ class Conductor {
519
689
  try {
520
690
  const msg = JSON.parse(line);
521
691
  this.handleAgentMessage(msg, issue, run);
692
+ const phase = inferPhase(msg);
693
+ if (phase) {
694
+ run.phase = phase;
695
+ }
696
+ if (msg.method === "item/commandExecution/started" || msg.method === "item/toolUse/started") {
697
+ run.toolCalls++;
698
+ }
699
+ const params = msg.params;
700
+ if (msg.method === "item/commandExecution/started" && params) {
701
+ const tool = (params.tool || params.name || "").toLowerCase();
702
+ if (tool.includes("edit") || tool.includes("write")) {
703
+ run.filesModified++;
704
+ }
705
+ }
706
+ if (msg.method === "item/text" && params?.text) {
707
+ run.tokensUsed += Math.ceil(params.text.length / 4);
708
+ }
709
+ if (run.toolCalls % 5 === 0 || phase) {
710
+ this.writeAgentStatus(issue.identifier, run);
711
+ }
522
712
  if (msg.method === "turn/completed") {
523
713
  turnCompleted = true;
714
+ this.writeAgentStatus(issue.identifier, run);
524
715
  }
525
716
  if (msg.method === "turn/failed") {
526
717
  turnCompleted = true;
718
+ this.writeAgentStatus(issue.identifier, run);
527
719
  const errMsg = msg.params?.error?.message || "Agent turn failed";
528
720
  clearTimeout(timer);
529
721
  reject(new Error(errMsg));
@@ -547,6 +739,10 @@ class Conductor {
547
739
  proc.on("close", (code) => {
548
740
  clearTimeout(timer);
549
741
  run.process = null;
742
+ if (run.logStream && !run.logStream.destroyed) {
743
+ run.logStream.end();
744
+ }
745
+ this.writeAgentStatus(issue.identifier, run);
550
746
  if (turnCompleted) {
551
747
  resolve();
552
748
  } else if (code === 0) {
@@ -785,5 +981,6 @@ class Conductor {
785
981
  }
786
982
  }
787
983
  export {
788
- Conductor
984
+ Conductor,
985
+ getAgentStatusDir
789
986
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.1",
4
- "description": "Project-scoped memory for AI coding tools. Durable context across sessions with 56 MCP tools, FTS5 search, Claude/Codex/OpenCode wrappers, Linear sync, automatic hooks, and log analysis.",
3
+ "version": "1.5.3",
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",
7
7
  "npm": ">=10.0.0"