teapot-coding-agent 0.37.56 → 0.37.58

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.
@@ -610,6 +610,8 @@ export class Agent {
610
610
  // so a background job records WHICH agent started it: sibling agents share a workspace,
611
611
  // and `isLive()` must not count their jobs as this one's work in flight
612
612
  agentId: opts.id,
613
+ // the job RECORD lives beside the session, not in a global dir: it is this agent's history
614
+ stateDir: opts.sessionDir,
613
615
  defaultTimeoutMs: 120_000,
614
616
  maxOutputBytes: 60_000,
615
617
  skillRoots: this.skillRoots,
@@ -655,7 +657,15 @@ export class Agent {
655
657
  /**
656
658
  * A short human title for this session, derived from the operator's first
657
659
  * prompt. Undefined until the log is read (or when there is no usable prompt).
660
+ *
661
+ * A MANUAL rename sets it too, and that must survive: the derivation above only fills it when it
662
+ * is still undefined, so an operator's name is never overwritten by the next restore. Without this
663
+ * path the header kept showing the old label after a rename — the value lives in `config.json`
664
+ * (`AgentConfig.sessionTitle`), and nothing wrote it into the live agent.
658
665
  */
666
+ setSessionTitle(title) {
667
+ this.sessionTitle = title?.trim() || undefined;
668
+ }
659
669
  sessionTitle;
660
670
  /** overflow recoveries used in the current turn; reset when a turn succeeds */
661
671
  overflowRecoveries = 0;
@@ -3,7 +3,7 @@
3
3
  * Tool specs are plain JSON-schema function definitions — provider-agnostic.
4
4
  */
5
5
  import { spawn } from "node:child_process";
6
- import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs";
6
+ import { existsSync, promises as fs, mkdirSync, readFileSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
7
7
  import path from "node:path";
8
8
  import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
9
9
  import { isReasoningEffort } from "../model-meta.js";
@@ -338,6 +338,62 @@ function toolShell() {
338
338
  export function elapsed(t0, now = Date.now()) {
339
339
  return `\n[took ${((now - t0) / 1000).toFixed(1)}s]`;
340
340
  }
341
+ const jobsFile = (dir) => path.join(dir, "bg-jobs.json");
342
+ /** read the persisted records, tolerating a missing or corrupt file (never throws) */
343
+ function readJobRecords(dir) {
344
+ try {
345
+ const raw = readFileSync(jobsFile(dir), "utf8");
346
+ const parsed = JSON.parse(raw);
347
+ return Array.isArray(parsed) ? parsed.filter((r) => r && typeof r.id === "string") : [];
348
+ }
349
+ catch {
350
+ return [];
351
+ }
352
+ }
353
+ /**
354
+ * Write the records atomically enough for this purpose.
355
+ *
356
+ * A torn write here is not worth a fsync: the file is a hint for the NEXT boot, and the worst case is
357
+ * losing one restart's worth of "this was running". Losing a job's identity entirely (the shipped
358
+ * behaviour) is what this exists to stop.
359
+ */
360
+ function writeJobRecords(dir, records) {
361
+ try {
362
+ mkdirSync(dir, { recursive: true });
363
+ writeFileSync(jobsFile(dir), JSON.stringify(records, null, 1));
364
+ }
365
+ catch {
366
+ /* a read-only session dir must not break the tool */
367
+ }
368
+ }
369
+ /** add or update one record (by id) */
370
+ function rememberJob(dir, rec) {
371
+ const all = readJobRecords(dir).filter((r) => r.id !== rec.id);
372
+ all.push(rec);
373
+ writeJobRecords(dir, all);
374
+ }
375
+ /** drop a record — the job finished AND was reported, or was killed */
376
+ function forgetJob(dir, id) {
377
+ writeJobRecords(dir, readJobRecords(dir).filter((r) => r.id !== id));
378
+ }
379
+ /**
380
+ * Jobs a PREVIOUS process started and whose processes may still be alive.
381
+ *
382
+ * `alive` is answered by the caller (pidExists) rather than here, so this module stays free of the
383
+ * signal-plumbing and the test can drive either answer. A record whose pid is gone is stale and is
384
+ * dropped: reporting a finished job as running is the same class of lie as forgetting a live one.
385
+ */
386
+ export function recoverableJobs(dir, alive) {
387
+ const all = readJobRecords(dir);
388
+ const live = [];
389
+ for (const r of all) {
390
+ if (r.pid !== null && alive(r.pid))
391
+ live.push(r);
392
+ }
393
+ if (live.length !== all.length)
394
+ writeJobRecords(dir, live);
395
+ return live;
396
+ }
341
397
  function startBackgroundShell(cmd, ctx) {
342
398
  const map = bgMapFor(ctx.cwd);
343
399
  // opportunistic cleanup of long-dead jobs so the map can't grow forever
@@ -367,6 +423,20 @@ function startBackgroundShell(cmd, ctx) {
367
423
  else
368
424
  sh.truncated = true;
369
425
  };
426
+ /**
427
+ * Persist the RECORD, so a later process can at least say this job exists.
428
+ *
429
+ * The `pid` is what makes it checkable on boot: a job whose pid is gone is dropped, one whose pid
430
+ * is alive is reported as "still running, output no longer reachable". That is the honest answer
431
+ * after a restart — see `ToolContext.stateDir`.
432
+ */
433
+ if (ctx.stateDir) {
434
+ rememberJob(ctx.stateDir, {
435
+ id, cmd, owner: ctx.agentId ?? "", workspace: ctx.cwd,
436
+ pid: child.pid ?? null,
437
+ startedAt: sh.startedAt,
438
+ });
439
+ }
370
440
  child.stdout?.on("data", collect);
371
441
  child.stderr?.on("data", collect);
372
442
  // "exit" (process gone) and "close" (all stdio drained) are DIFFERENT events.
@@ -389,6 +459,9 @@ function startBackgroundShell(cmd, ctx) {
389
459
  if (exitNotified)
390
460
  return;
391
461
  exitNotified = true;
462
+ // the job is over: its record must not claim otherwise on the next boot
463
+ if (ctx.stateDir)
464
+ forgetJob(ctx.stateDir, id);
392
465
  // tell the harness so the agent learns the outcome WITHOUT polling
393
466
  try {
394
467
  ctx.onBackgroundExit?.({
@@ -425,6 +498,109 @@ export function isContextOverflow(err) {
425
498
  * its work on `bash(background=true)` — dev server, watcher, long build —
426
499
  * must NOT be nagged with "Continue working toward the current goal". The
427
500
  * job's exit notification will wake the agent on its own. */
501
+ /**
502
+ * The background shells for a workspace, as the UI sees them.
503
+ *
504
+ * Only a VIEW: no child process, no cursors. The `out` field is the tail kept for `bash_output`, so
505
+ * showing it costs nothing extra — the buffer is already there.
506
+ *
507
+ * `owner` is included because several agents share one workspace (every sub-agent inherits its
508
+ * parent's), and "whose job is this" is the first thing an operator asks when deciding what to kill.
509
+ */
510
+ export function listBgShells(cwd, stateDir) {
511
+ const live = liveShells(cwd);
512
+ /**
513
+ * Records from a previous process are appended, marked `detached`.
514
+ *
515
+ * Without them a job that survived a restart is INVISIBLE — MEASURED: a 3-hour GPU sweep kept
516
+ * writing while the panel showed nothing and `bash_output` said "forgotten on harness restart".
517
+ * The process cannot be re-attached (no fd survives the parent), so `detached` is the whole truth
518
+ * the UI can be given: it is running, and its output lives wherever the command redirected it.
519
+ */
520
+ if (!stateDir)
521
+ return live;
522
+ const known = new Set(live.map((j) => j.id));
523
+ const recovered = recoverableJobs(stateDir, pidExists)
524
+ .filter((r) => !known.has(r.id) && r.workspace === cwd)
525
+ .map((r) => ({
526
+ id: r.id,
527
+ cmd: r.cmd,
528
+ owner: r.owner,
529
+ running: true,
530
+ detached: true,
531
+ pid: r.pid,
532
+ code: null,
533
+ startedAt: r.startedAt,
534
+ tail: "",
535
+ bytes: 0,
536
+ truncated: false,
537
+ }));
538
+ return [...live, ...recovered].sort((a, b) => b.startedAt - a.startedAt);
539
+ }
540
+ /** does this pid exist? (EPERM means it exists but is not ours to signal) */
541
+ function pidExists(pid) {
542
+ try {
543
+ process.kill(pid, 0);
544
+ return true;
545
+ }
546
+ catch (err) {
547
+ return err.code === "EPERM";
548
+ }
549
+ }
550
+ /** the jobs THIS process is running, as the UI sees them */
551
+ function liveShells(cwd) {
552
+ const m = bgShellsByWs.get(cwd);
553
+ if (!m)
554
+ return [];
555
+ return [...m.values()]
556
+ .map((b) => ({
557
+ id: b.id,
558
+ cmd: b.cmd,
559
+ owner: b.owner,
560
+ running: !b.exited,
561
+ code: b.code,
562
+ startedAt: b.startedAt,
563
+ // the LAST slice: a job is watched to see what it is doing NOW, and the head of a 10MB log is
564
+ // the least interesting part of it
565
+ tail: b.out.slice(-4000),
566
+ bytes: b.out.length,
567
+ truncated: b.truncated,
568
+ }))
569
+ .sort((a, b) => b.startedAt - a.startedAt);
570
+ }
571
+ /**
572
+ * Kill ONE background job; returns whether it existed.
573
+ *
574
+ * A DETACHED job (a record from a previous process) is handled differently on purpose: the harness no
575
+ * longer owns that process, so this drops the RECORD and does NOT signal the pid. Killing a pid we
576
+ * merely remember is how an unrelated process gets killed after pid reuse — and the operator's own
577
+ * instruction was to never touch a job from an earlier session. The caller reports what happened.
578
+ */
579
+ export function killBgShell(cwd, id, stateDir) {
580
+ const m = bgShellsByWs.get(cwd);
581
+ const b = m?.get(id);
582
+ if (b) {
583
+ try {
584
+ killTree(b.child);
585
+ }
586
+ catch {
587
+ /* already gone */
588
+ }
589
+ m.delete(id);
590
+ if (stateDir)
591
+ forgetJob(stateDir, id);
592
+ return true;
593
+ }
594
+ // not ours any more: forget it, leave the process alone
595
+ if (stateDir) {
596
+ const rec = readJobRecords(stateDir).find((r) => r.id === id);
597
+ if (rec) {
598
+ forgetJob(stateDir, id);
599
+ return true;
600
+ }
601
+ }
602
+ return false;
603
+ }
428
604
  export function hasRunningBgShells(cwd) {
429
605
  const m = bgShellsByWs.get(cwd);
430
606
  if (!m)
package/dist/master.js CHANGED
@@ -2098,6 +2098,36 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
2098
2098
  * the second: it deleted the config entry, which orphaned the session directory
2099
2099
  * (the files survived, but nothing in the UI could reach them again).
2100
2100
  */
2101
+ /**
2102
+ * Rename a session by hand.
2103
+ *
2104
+ * The stored title is what every surface shows (the sidebar row, the header, the tab), and
2105
+ * `ensureSessionTitle` deliberately never overwrites an existing value — its own comment says so
2106
+ * ("which is also what will let the operator rename a session by hand"). This is that path.
2107
+ *
2108
+ * Refuses an empty name rather than storing one: an empty `sessionTitle` is indistinguishable
2109
+ * from "never derived", so the next `ensureSessionTitle` would silently re-derive and the
2110
+ * operator's intent would vanish. Clearing to the derived default is what `reset` is for.
2111
+ */
2112
+ async renameSession(agentId, sessionId, title) {
2113
+ const a = this.config.agents.find((x) => x.id === agentId);
2114
+ if (!a)
2115
+ throw new Error(`no such agent: ${agentId}`);
2116
+ // the title names THAT session, so it may only be set on the one the agent is on
2117
+ if (a.session !== sessionId)
2118
+ throw new Error(`agent ${agentId} is not on session ${sessionId}`);
2119
+ const clean = title.replace(/\s+/g, " ").trim().slice(0, 80);
2120
+ if (!clean)
2121
+ throw new Error("a session title cannot be empty");
2122
+ a.sessionTitle = clean;
2123
+ this.saveConfig();
2124
+ // the live agent derives its own title from the log, so it has to be told — otherwise the header
2125
+ // and sidebar keep the old label until a restart re-reads the config
2126
+ this.agents.get(agentId)?.setSessionTitle(clean);
2127
+ // the sidebar caches titles by session id, so it has to learn about this one
2128
+ bus.emit("update", { kind: "agent-update", agentId });
2129
+ return { title: clean };
2130
+ }
2101
2131
  async archiveAgent(id) {
2102
2132
  const root = this.config.agents.find((a) => a.id === id);
2103
2133
  if (!root)
@@ -13,6 +13,7 @@ import { promises as fs } from "node:fs";
13
13
  import { fileURLToPath } from "node:url";
14
14
  import { parseSchedule } from "../scheduler/cron.js";
15
15
  import { attachTerminal, closeTerminal, closeTerminalsFor, createTerminal, detachTerminal, forgetTerminalSize, listTerminals, reapIdleTerminals, resizeTerminal, setTerminalResize, writeTerminal, } from "./terminals.js";
16
+ import { listBgShells, killBgShell } from "../agent/tools.js";
16
17
  import { applyPtySize, TermSizeTracker, resolvePtySlave } from "./term-size.js";
17
18
  import { expandTilde, homeDir } from "../home.js";
18
19
  import { SUB_PERSONAS, resolveWorkspace } from "../master.js";
@@ -611,7 +612,55 @@ export function buildApp(master) {
611
612
  // archive / restore / purge. "remove" used to mean "delete the config entry and
612
613
  // orphan the history on disk"; these make the three intents distinct — put it
613
614
  // away (reversible), bring it back, or destroy it for real.
615
+ /**
616
+ * Background shells for an agent's workspace.
617
+ *
618
+ * Scoped by AGENT rather than by workspace path: the operator thinks in agents, and a workspace is
619
+ * a shared thing several of them use. The registry keys by workspace (that is what needs killing),
620
+ * so the lookup is `agent.workspace` here and the response carries `owner` for each job.
621
+ *
622
+ * Read-only apart from the DELETE. There is deliberately no "start a job" route: background shells
623
+ * are an AGENT's tool, and a job the operator started would have no agent to notify.
624
+ */
625
+ app.get("/api/agents/:id/bg-jobs", (c) => {
626
+ const agent = master.agents.get(c.req.param("id"));
627
+ if (!agent)
628
+ return c.json({ error: "no such agent" }, 404);
629
+ const snap = agent.snapshot();
630
+ return c.json({ jobs: listBgShells(snap.workspace ?? "", snap.sessionDir) });
631
+ });
632
+ app.delete("/api/agents/:id/bg-jobs/:job", (c) => {
633
+ const agent = master.agents.get(c.req.param("id"));
634
+ if (!agent)
635
+ return c.json({ error: "no such agent" }, 404);
636
+ const snap = agent.snapshot();
637
+ const ok = killBgShell(snap.workspace ?? "", c.req.param("job"), snap.sessionDir);
638
+ return c.json({ ok }, ok ? 200 : 404);
639
+ });
614
640
  app.get("/api/archived", (c) => c.json({ agents: master.archivedAgents() }));
641
+ /**
642
+ * Rename the session the operator is looking at.
643
+ *
644
+ * `?session=` is required rather than inferred: a title names a SPECIFIC session, and an agent can
645
+ * own several, so defaulting to the agent's current one would rename the wrong thing whenever the
646
+ * UI was showing a past session of it.
647
+ */
648
+ app.put("/api/agents/:id/session-title", async (c) => {
649
+ const sessionId = c.req.query("session") ?? "";
650
+ if (!sessionId)
651
+ return c.json({ error: "?session= is required" }, 400);
652
+ const body = await c.req.json().catch(() => null);
653
+ if (!body || typeof body.title !== "string")
654
+ return c.json({ error: "title is required" }, 400);
655
+ try {
656
+ return c.json(await master.renameSession(c.req.param("id"), sessionId, body.title));
657
+ }
658
+ catch (err) {
659
+ const msg = err.message;
660
+ const code = /no such agent|not on session/.test(msg) ? 404 : 400;
661
+ return c.json({ error: msg }, code);
662
+ }
663
+ });
615
664
  app.post("/api/agents/:id/archive", async (c) => {
616
665
  try {
617
666
  return c.json(await master.archiveAgent(c.req.param("id")));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "teapot-coding-agent",
3
- "version": "0.37.56",
3
+ "version": "0.37.58",
4
4
  "description": "A lightweight, always-on multi-agent harness for AI coding agents",
5
5
  "type": "module",
6
6
  "license": "AGPL-3.0-or-later",