@stackmemoryai/stackmemory 1.5.2 → 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"],
@@ -130,6 +196,7 @@ class Conductor {
130
196
  console.log(
131
197
  `Orchestrator started \u2014 polling every ${this.config.pollIntervalMs / 1e3}s, max ${this.config.maxConcurrent} concurrent`
132
198
  );
199
+ this.writeStatusFile();
133
200
  const shutdown = () => this.stop();
134
201
  process.on("SIGINT", shutdown);
135
202
  process.on("SIGTERM", shutdown);
@@ -175,6 +242,7 @@ class Conductor {
175
242
  }
176
243
  this.running.clear();
177
244
  this.claimed.clear();
245
+ this.clearStatusFile();
178
246
  console.log(
179
247
  `Orchestrator stopped. Completed: ${this.completeCount}, Failed: ${this.failCount}`
180
248
  );
@@ -198,6 +266,82 @@ class Conductor {
198
266
  issues
199
267
  };
200
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
+ }
201
345
  // ── Polling ──
202
346
  async schedulePoll() {
203
347
  while (!this.stopping) {
@@ -210,6 +354,7 @@ class Conductor {
210
354
  } catch (err) {
211
355
  logger.error("Poll cycle failed", { error: err.message });
212
356
  }
357
+ this.writeStatusFile();
213
358
  }
214
359
  }
215
360
  async poll() {
@@ -337,8 +482,13 @@ class Conductor {
337
482
  process: null,
338
483
  attempt: 1,
339
484
  startedAt: Date.now(),
340
- status: "starting"
485
+ status: "starting",
486
+ phase: "reading",
487
+ toolCalls: 0,
488
+ filesModified: 0,
489
+ tokensUsed: 0
341
490
  };
491
+ this.writeAgentStatus(issue.identifier, run);
342
492
  this.running.set(issueId, run);
343
493
  this.totalAttempts++;
344
494
  console.log(`[${issue.identifier}] Dispatching: ${issue.title}`);
@@ -347,23 +497,26 @@ class Conductor {
347
497
  run.workspacePath = workspacePath;
348
498
  await this.transitionIssue(issue, this.config.inProgressState);
349
499
  await this.runHook("after-create", workspacePath, issue);
350
- run.status = "running";
351
- await this.runAgent(issue, run);
352
- run.status = "completed";
353
- this.completeCount++;
354
- await this.runHook("after-run", workspacePath, issue, run.attempt);
355
- this.takeSnapshot(workspacePath, issue);
356
- await this.transitionIssue(issue, this.config.inReviewState);
357
- console.log(`[${issue.identifier}] Completed successfully`);
500
+ await this.attemptRun(issue, run);
358
501
  } catch (err) {
359
- run.status = "failed";
360
- run.error = err.message;
361
- logger.error("Issue dispatch failed", {
362
- identifier: issue.identifier,
363
- error: run.error,
364
- attempt: run.attempt
365
- });
366
- 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++;
367
520
  await this.runHook(
368
521
  "after-run",
369
522
  run.workspacePath,
@@ -371,47 +524,44 @@ class Conductor {
371
524
  run.attempt
372
525
  ).catch(() => {
373
526
  });
374
- }
375
- if (run.attempt < this.config.maxRetries + 1) {
527
+ this.takeSnapshot(run.workspacePath, issue);
528
+ await this.transitionIssue(issue, this.config.inReviewState);
376
529
  console.log(
377
- `[${issue.identifier}] Failed (attempt ${run.attempt}), retrying...`
530
+ run.attempt === 1 ? `[${issue.identifier}] Completed successfully` : `[${issue.identifier}] Completed on retry ${run.attempt}`
378
531
  );
379
- run.attempt++;
380
- this.totalAttempts++;
381
- const backoffMs = Math.min(1e3 * Math.pow(2, run.attempt - 1), 3e5);
382
- await new Promise((r) => setTimeout(r, backoffMs));
383
- if (!this.stopping) {
384
- try {
385
- run.status = "running";
386
- await this.runAgent(issue, run);
387
- run.status = "completed";
388
- this.completeCount++;
389
- await this.runHook(
390
- "after-run",
391
- run.workspacePath,
392
- issue,
393
- run.attempt
394
- ).catch(() => {
395
- });
396
- await this.transitionIssue(issue, this.config.inReviewState);
397
- console.log(
398
- `[${issue.identifier}] Completed on retry ${run.attempt}`
399
- );
400
- } catch (retryErr) {
401
- run.status = "failed";
402
- run.error = retryErr.message;
403
- this.failCount++;
404
- console.log(
405
- `[${issue.identifier}] Failed after ${run.attempt} attempts: ${run.error}`
406
- );
407
- }
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;
408
563
  }
409
- } else {
410
- this.failCount++;
411
- console.log(`[${issue.identifier}] Failed: ${run.error}`);
412
564
  }
413
- } finally {
414
- this.running.delete(issueId);
415
565
  }
416
566
  }
417
567
  // ── Workspace Management ──
@@ -507,6 +657,11 @@ class Conductor {
507
657
  stdio: ["pipe", "pipe", "pipe"]
508
658
  });
509
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);
510
665
  let stderr = "";
511
666
  let turnCompleted = false;
512
667
  const timer = setTimeout(() => {
@@ -525,7 +680,7 @@ class Conductor {
525
680
  proc.stdin.write(JSON.stringify(msg) + "\n");
526
681
  };
527
682
  let lineBuffer = "";
528
- proc.stdout.on("data", (chunk) => {
683
+ tee.on("data", (chunk) => {
529
684
  lineBuffer += chunk.toString();
530
685
  const lines = lineBuffer.split("\n");
531
686
  lineBuffer = lines.pop() || "";
@@ -534,11 +689,33 @@ class Conductor {
534
689
  try {
535
690
  const msg = JSON.parse(line);
536
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
+ }
537
712
  if (msg.method === "turn/completed") {
538
713
  turnCompleted = true;
714
+ this.writeAgentStatus(issue.identifier, run);
539
715
  }
540
716
  if (msg.method === "turn/failed") {
541
717
  turnCompleted = true;
718
+ this.writeAgentStatus(issue.identifier, run);
542
719
  const errMsg = msg.params?.error?.message || "Agent turn failed";
543
720
  clearTimeout(timer);
544
721
  reject(new Error(errMsg));
@@ -562,6 +739,10 @@ class Conductor {
562
739
  proc.on("close", (code) => {
563
740
  clearTimeout(timer);
564
741
  run.process = null;
742
+ if (run.logStream && !run.logStream.destroyed) {
743
+ run.logStream.end();
744
+ }
745
+ this.writeAgentStatus(issue.identifier, run);
565
746
  if (turnCompleted) {
566
747
  resolve();
567
748
  } else if (code === 0) {
@@ -800,5 +981,6 @@ class Conductor {
800
981
  }
801
982
  }
802
983
  export {
803
- Conductor
984
+ Conductor,
985
+ getAgentStatusDir
804
986
  };
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@stackmemoryai/stackmemory",
3
- "version": "1.5.2",
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"