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.
- package/dist/agent/agent.js +10 -0
- package/dist/agent/tools.js +177 -1
- package/dist/master.js +30 -0
- package/dist/server/api.js +49 -0
- package/package.json +1 -1
- package/public/assets/index-D_f1aal3.js +36 -0
- package/public/assets/index-D_f1aal3.js.map +1 -0
- package/public/assets/index-mEDytv26.css +1 -0
- package/public/assets/{shiki-8dPimORy.js → shiki-BaLUHSNL.js} +2 -2
- package/public/assets/{shiki-8dPimORy.js.map → shiki-BaLUHSNL.js.map} +1 -1
- package/public/index.html +2 -2
- package/public/assets/index-4VO6LraC.css +0 -1
- package/public/assets/index-C__Vhkqd.js +0 -36
- package/public/assets/index-C__Vhkqd.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -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;
|
package/dist/agent/tools.js
CHANGED
|
@@ -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)
|
package/dist/server/api.js
CHANGED
|
@@ -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")));
|