teapot-coding-agent 0.25.2 → 0.26.1
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 +201 -12
- package/dist/agent/tools.js +127 -16
- package/dist/master.js +55 -3
- package/dist/server/api.js +130 -6
- package/package.json +1 -1
- package/public/assets/index-Cm_AmGsg.js +30 -0
- package/public/assets/index-Cm_AmGsg.js.map +1 -0
- package/public/assets/{shiki-N1Dpty1W.js → shiki-OoTC7Aoo.js} +2 -2
- package/public/assets/{shiki-N1Dpty1W.js.map → shiki-OoTC7Aoo.js.map} +1 -1
- package/public/index.html +1 -1
- package/public/assets/index-DDx2AmuD.js +0 -30
- package/public/assets/index-DDx2AmuD.js.map +0 -1
package/dist/agent/agent.js
CHANGED
|
@@ -345,6 +345,46 @@ export class Agent {
|
|
|
345
345
|
get compactBudgetIsManual() {
|
|
346
346
|
return this.manualCompactBudget;
|
|
347
347
|
}
|
|
348
|
+
/**
|
|
349
|
+
* A context size we are willing to act on.
|
|
350
|
+
*
|
|
351
|
+
* #73: `lastUsage.input` is the provider's own count, so it is normally the
|
|
352
|
+
* most accurate number available — but a provider that under-reports (a proxy
|
|
353
|
+
* that omits the field's real value, a broken gateway, a model served through
|
|
354
|
+
* a shim) makes BOTH safety gates believe the context is nearly empty:
|
|
355
|
+
*
|
|
356
|
+
* maybePrune() est < budget * 0.6 -> 0 prunes, forever
|
|
357
|
+
* maybeCompact() before < budget -> 0 compactions, forever
|
|
358
|
+
*
|
|
359
|
+
* Reproduced: ~40k characters of real conversation, provider reporting
|
|
360
|
+
* `prompt_tokens: 10` — zero prunes, zero compactions, and a gauge reading 10,
|
|
361
|
+
* so nothing looks wrong. The context then overflows for real, with the
|
|
362
|
+
* defence that exists to stop exactly that switched off.
|
|
363
|
+
*
|
|
364
|
+
* So the reported number is used, but never as the ONLY number: it is floored
|
|
365
|
+
* against the local estimate. The estimate is a heuristic; the point is that
|
|
366
|
+
* `??` was the wrong way to combine a measurement with a floor — it only falls
|
|
367
|
+
* back when the value is ABSENT, so a confidently wrong value was trusted more
|
|
368
|
+
* than the guess.
|
|
369
|
+
*
|
|
370
|
+
* The floor is a HALF of the local estimate. A quarter was tried first and
|
|
371
|
+
* was too weak to clear the 0.6-of-budget threshold for a realistic session
|
|
372
|
+
* (estimate 5108 → floored 1277 against a 3600 bar), so the guard still would
|
|
373
|
+
* not have fired. Half keeps the intent — catch "reported 10 when the real
|
|
374
|
+
* prompt is large" — without arguing with a provider whose tokenizer
|
|
375
|
+
* legitimately differs from ours, since a provider within 2x of the estimate
|
|
376
|
+
* is not the failure mode being defended against.
|
|
377
|
+
*/
|
|
378
|
+
contextSizeForBudgeting() {
|
|
379
|
+
const estimated = this.estimateTokens();
|
|
380
|
+
const reported = this.lastUsage?.input;
|
|
381
|
+
if (reported === undefined)
|
|
382
|
+
return estimated;
|
|
383
|
+
// a provider reporting nothing at all is a different bug; treat it as unknown
|
|
384
|
+
if (!Number.isFinite(reported) || reported <= 0)
|
|
385
|
+
return estimated;
|
|
386
|
+
return Math.max(reported, Math.floor(estimated / 2));
|
|
387
|
+
}
|
|
348
388
|
/**
|
|
349
389
|
* Is this agent doing work that `stop()` would actually stop?
|
|
350
390
|
*
|
|
@@ -722,10 +762,19 @@ export class Agent {
|
|
|
722
762
|
droppedEvents: dropped.length,
|
|
723
763
|
tailMode: tail,
|
|
724
764
|
});
|
|
725
|
-
// Seed the new branch's parent link
|
|
726
|
-
//
|
|
727
|
-
//
|
|
728
|
-
|
|
765
|
+
// Seed the new branch's parent link so its first event chains across
|
|
766
|
+
// instead of starting a disconnected parent:null history (#38).
|
|
767
|
+
//
|
|
768
|
+
// #69: seed from the CUT POINT, not from this branch's tip. The fork event
|
|
769
|
+
// records where the edit was made (`fromEvent` = the last KEPT event), but
|
|
770
|
+
// `lastEventId(currentBranch)` is the newest event on the branch — which is
|
|
771
|
+
// one of the events this edit just DISCARDED. Seeding from the tip chained
|
|
772
|
+
// the new branch to the discarded tail, so lineageOf() walked straight back
|
|
773
|
+
// through it and a restart resurrected every dropped turn.
|
|
774
|
+
//
|
|
775
|
+
// The cut point is the right parent: it is the last event that survives.
|
|
776
|
+
const cutPoint = kept.at(-1)?.id ?? null;
|
|
777
|
+
this.log.seedBranch(newBranch, cutPoint);
|
|
729
778
|
this.currentBranch = newBranch;
|
|
730
779
|
this.messages = msgs;
|
|
731
780
|
await this.log.append("prompt", this.currentSession, this.currentBranch, { source: "user", text });
|
|
@@ -940,6 +989,17 @@ export class Agent {
|
|
|
940
989
|
...(this.compactPhase ? { compacting: this.compactPhase } : {}),
|
|
941
990
|
},
|
|
942
991
|
pendingPrompts: this.pendingPrompts.length,
|
|
992
|
+
// #78: the IDS as well as the count. The web UI reconciles its local echo
|
|
993
|
+
// list against the live queue, and it was doing that by POSITION
|
|
994
|
+
// (slice(0, queued)) because only a count was published — so whenever the
|
|
995
|
+
// count dipped below the true queue length (a mid-queue cancellation, or a
|
|
996
|
+
// momentary gap), the NEWEST still-queued echoes were dropped and their
|
|
997
|
+
// undelivered log rows reappeared as settled "sent" rows. Identity needs
|
|
998
|
+
// the ids to travel; the count alone cannot express "these two".
|
|
999
|
+
//
|
|
1000
|
+
// Bounded like the rest of the snapshot, and ids are opaque, so this
|
|
1001
|
+
// cannot carry prompt text.
|
|
1002
|
+
pendingPromptIds: this.pendingPrompts.map((p) => p.id).filter((x) => !!x).slice(0, 64),
|
|
943
1003
|
todo: this.todo.slice(0, 32_000), // match set_todo's cap — no silent truncation
|
|
944
1004
|
parent: this.opts.parent,
|
|
945
1005
|
awaiting: this.awaitingUser,
|
|
@@ -1114,9 +1174,24 @@ export class Agent {
|
|
|
1114
1174
|
}
|
|
1115
1175
|
/** Start (or resume) autonomous operation toward the goal. */
|
|
1116
1176
|
start(reason = "start") {
|
|
1117
|
-
|
|
1177
|
+
// #76: a stop() immediately followed by start() USED TO BE SILENTLY LOST.
|
|
1178
|
+
//
|
|
1179
|
+
// `stop()` only flips the status via an ENQUEUED task, so between the two
|
|
1180
|
+
// calls the status is still "running" and the guard below returned early —
|
|
1181
|
+
// before `stopRequested = false` could run. The stop then landed, the loop
|
|
1182
|
+
// exited, and the agent ended "stopped" with the restart discarded. The same
|
|
1183
|
+
// window made a stop+restart abort the new round's first tool.
|
|
1184
|
+
//
|
|
1185
|
+
// So a pending stop is not a reason to refuse a start. The loop's own
|
|
1186
|
+
// guards (`stopRequested` re-checked after every await) keep the old turn
|
|
1187
|
+
// from running on, and enqueue() serialises the two tasks, so the restart
|
|
1188
|
+
// is ordered after the stop rather than racing it.
|
|
1189
|
+
const pendingStop = this.stopRequested;
|
|
1190
|
+
if (this.status === "running" && !pendingStop)
|
|
1118
1191
|
return;
|
|
1119
1192
|
this.stopRequested = false;
|
|
1193
|
+
// claim the status against any stop write still queued behind us (#86)
|
|
1194
|
+
this.stopSeq++;
|
|
1120
1195
|
this.awaitingUser = false; // a fresh start answers/resumes past any ask_user
|
|
1121
1196
|
void this.enqueue(async () => {
|
|
1122
1197
|
await this.ensureReady(); // lazy restore before the loop touches history
|
|
@@ -1133,11 +1208,32 @@ export class Agent {
|
|
|
1133
1208
|
this.abort?.abort();
|
|
1134
1209
|
this.toolAbort.abort();
|
|
1135
1210
|
this.wake?.();
|
|
1211
|
+
// #86: the status write is DEFERRED onto the run chain, so it can land
|
|
1212
|
+
// AFTER a start() that followed it. Observed: `idle -> stopped -> running ->
|
|
1213
|
+
// running -> idle`. The agent did resume — but it flashed "stopped" on the
|
|
1214
|
+
// way, and finished idle rather than running, so from the UI the operator's
|
|
1215
|
+
// ▶ start looked like it had done nothing.
|
|
1216
|
+
//
|
|
1217
|
+
// `start()` clears stopRequested synchronously, which is what makes this
|
|
1218
|
+
// detectable: by the time this task runs, a start has already claimed the
|
|
1219
|
+
// agent and the stop no longer owns the status.
|
|
1220
|
+
//
|
|
1221
|
+
// So record WHEN the stop was requested, and let a start that came after it
|
|
1222
|
+
// win. A stop issued while the agent is genuinely still running behaves
|
|
1223
|
+
// exactly as before.
|
|
1224
|
+
const stopSeq = ++this.stopSeq;
|
|
1136
1225
|
void this.enqueue(() => {
|
|
1226
|
+
// Only a start() that arrived AFTER this stop may claim the status. The
|
|
1227
|
+
// seq check alone does that — no status test, because a genuinely running
|
|
1228
|
+
// agent must still be reported "stopped" (#86).
|
|
1229
|
+
if (this.stopSeq !== stopSeq)
|
|
1230
|
+
return Promise.resolve();
|
|
1137
1231
|
this.setStatus("stopped", reason);
|
|
1138
1232
|
return Promise.resolve();
|
|
1139
1233
|
});
|
|
1140
1234
|
}
|
|
1235
|
+
/** monotonic counter: a start() bumps it, so a pending stop status write loses */
|
|
1236
|
+
stopSeq = 0;
|
|
1141
1237
|
setStatus(s, reason = "") {
|
|
1142
1238
|
// Update the field FIRST. log.append fires onEvent synchronously, and the
|
|
1143
1239
|
// master's child-event hook wakes parked wait_children callers — those
|
|
@@ -1318,6 +1414,13 @@ export class Agent {
|
|
|
1318
1414
|
const name = err.name;
|
|
1319
1415
|
if (name === "StopRequested" || name === "AbortError" || this.stopRequested)
|
|
1320
1416
|
throw err;
|
|
1417
|
+
// #76: a context-overflow error is the caller's to handle, not ours to
|
|
1418
|
+
// retry. Retrying the SAME oversized request four times with 5s/5s/30s
|
|
1419
|
+
// waits cannot succeed, and it delays the compact-and-retry that would
|
|
1420
|
+
// — so the overflow path never even ran. This is the same
|
|
1421
|
+
// "present, correct, and inert" shape as #54.
|
|
1422
|
+
if (isContextOverflow(err))
|
|
1423
|
+
throw err;
|
|
1321
1424
|
if (attempt >= maxAttempts)
|
|
1322
1425
|
throw err;
|
|
1323
1426
|
const waitMs = waits[Math.min(attempt - 1, waits.length - 1)];
|
|
@@ -1505,8 +1608,15 @@ export class Agent {
|
|
|
1505
1608
|
return finished;
|
|
1506
1609
|
}
|
|
1507
1610
|
for (const call of m.tool_calls) {
|
|
1508
|
-
if (this.stopRequested)
|
|
1611
|
+
if (this.stopRequested) {
|
|
1612
|
+
// #54: this returned straight out of the turn, SKIPPING the
|
|
1613
|
+
// answerUnansweredToolCalls backstop below — so every remaining
|
|
1614
|
+
// tool_call in this assistant message stayed logged with NO
|
|
1615
|
+
// tool_result anywhere, and its row rendered as "waiting for
|
|
1616
|
+
// output…" for the rest of the session.
|
|
1617
|
+
await this.answerUnansweredToolCalls(m);
|
|
1509
1618
|
return finished;
|
|
1619
|
+
}
|
|
1510
1620
|
if (call.function.name === "finish") {
|
|
1511
1621
|
this.pendingSubReport = ""; // the real summary supersedes it
|
|
1512
1622
|
await this.handleFinish(call.function.arguments);
|
|
@@ -1585,6 +1695,13 @@ export class Agent {
|
|
|
1585
1695
|
this.awaitingUser = true;
|
|
1586
1696
|
this.setStatus("waiting", question.slice(0, 80));
|
|
1587
1697
|
// control flow: park the loop; the operator's next prompt resumes it
|
|
1698
|
+
//
|
|
1699
|
+
// #54: answer the rest of this assistant batch BEFORE parking. The
|
|
1700
|
+
// throw leaves the whole tool loop, so any EARLIER call in the same
|
|
1701
|
+
// message that was logged but not yet run kept no tool_result — the
|
|
1702
|
+
// comment on the backstop below says as much, and it is exactly the
|
|
1703
|
+
// path that bites in practice because ask_user is common.
|
|
1704
|
+
await this.answerUnansweredToolCalls(m);
|
|
1588
1705
|
throw Object.assign(new Error("waiting for user"), { name: "WaitForUser" });
|
|
1589
1706
|
}
|
|
1590
1707
|
if (call.function.name === "get_todo") {
|
|
@@ -1716,8 +1833,23 @@ export class Agent {
|
|
|
1716
1833
|
});
|
|
1717
1834
|
const t0 = Date.now();
|
|
1718
1835
|
// first filesystem touch creates a missing workspace (never at boot)
|
|
1719
|
-
|
|
1720
|
-
|
|
1836
|
+
// #54: this is awaited BETWEEN the tool_call append above and the
|
|
1837
|
+
// tool_result append below, and `fs.mkdir` has no catch — so a failure
|
|
1838
|
+
// propagated out of the whole loop, leaving the call logged with no
|
|
1839
|
+
// result ANYWHERE, and its row read "waiting for output…" for good.
|
|
1840
|
+
//
|
|
1841
|
+
// The loop's own backstop could not save it: it sits at the bottom of
|
|
1842
|
+
// the same loop, which a throw skips. So the tool_result is written in a
|
|
1843
|
+
// `finally` — the call is answered whatever happens.
|
|
1844
|
+
// the type is inferred from executeTool, so this cannot drift from it
|
|
1845
|
+
let result;
|
|
1846
|
+
try {
|
|
1847
|
+
await this.ensureWorkspace();
|
|
1848
|
+
result = await executeTool(call.function.name, call.function.arguments, this.toolCtx);
|
|
1849
|
+
}
|
|
1850
|
+
catch (err) {
|
|
1851
|
+
result = { ok: false, result: `tool could not run: ${err.message}` };
|
|
1852
|
+
}
|
|
1721
1853
|
this.stats.toolCalls++;
|
|
1722
1854
|
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
1723
1855
|
callId: call.id,
|
|
@@ -1743,6 +1875,20 @@ export class Agent {
|
|
|
1743
1875
|
`(last: ${call.function.name})`);
|
|
1744
1876
|
}
|
|
1745
1877
|
}
|
|
1878
|
+
// #76: every tool_call an assistant message issued must have a matching
|
|
1879
|
+
// tool result, or the provider REJECTS the next request — which is the
|
|
1880
|
+
// "`200 with no choices`" shape #50 traced to a malformed history.
|
|
1881
|
+
//
|
|
1882
|
+
// Two exits could leave some unanswered: `stopRequested` returns early
|
|
1883
|
+
// mid-batch, and a handler that throws (ask_user parking the loop is the
|
|
1884
|
+
// one that bites in practice) propagates out of the whole loop. Restore
|
|
1885
|
+
// has a hole-filler for exactly this; the LIVE array did not, so a session
|
|
1886
|
+
// that hit it produced an invalid next request and then failed with
|
|
1887
|
+
// something that looked like a provider fault.
|
|
1888
|
+
//
|
|
1889
|
+
// So the invariant is enforced here rather than at each exit: whatever
|
|
1890
|
+
// happened, answer what is still outstanding before moving on.
|
|
1891
|
+
await this.answerUnansweredToolCalls(m);
|
|
1746
1892
|
if (finished)
|
|
1747
1893
|
return true;
|
|
1748
1894
|
}
|
|
@@ -1750,6 +1896,37 @@ export class Agent {
|
|
|
1750
1896
|
// nudges the model; this round simply ends (no hard error)
|
|
1751
1897
|
return finished;
|
|
1752
1898
|
}
|
|
1899
|
+
/**
|
|
1900
|
+
* Append a tool result for every tool_call in `m` that has not been answered.
|
|
1901
|
+
*
|
|
1902
|
+
* Scans the tail of `messages` for `tool_call_id`s, so it is correct whichever
|
|
1903
|
+
* path left the batch unfinished — a stop, a throw, or a normal completion
|
|
1904
|
+
* where a handler returned early. Answered calls are left alone.
|
|
1905
|
+
*/
|
|
1906
|
+
async answerUnansweredToolCalls(m) {
|
|
1907
|
+
const calls = m.tool_calls ?? [];
|
|
1908
|
+
if (!calls.length)
|
|
1909
|
+
return;
|
|
1910
|
+
const answered = new Set();
|
|
1911
|
+
for (const existing of this.messages) {
|
|
1912
|
+
if (existing.role === "tool" && existing.tool_call_id)
|
|
1913
|
+
answered.add(existing.tool_call_id);
|
|
1914
|
+
}
|
|
1915
|
+
for (const call of calls) {
|
|
1916
|
+
if (answered.has(call.id))
|
|
1917
|
+
continue;
|
|
1918
|
+
const content = "(not completed — the tool did not run)";
|
|
1919
|
+
this.messages.push({ role: "tool", tool_call_id: call.id, content });
|
|
1920
|
+
await this.log.append("tool_result", this.currentSession, this.currentBranch, {
|
|
1921
|
+
callId: call.id,
|
|
1922
|
+
name: call.function.name,
|
|
1923
|
+
ok: false,
|
|
1924
|
+
durationMs: 0,
|
|
1925
|
+
result: content,
|
|
1926
|
+
synthesized: true,
|
|
1927
|
+
});
|
|
1928
|
+
}
|
|
1929
|
+
}
|
|
1753
1930
|
buildMessages() {
|
|
1754
1931
|
// deliberately static: [system] + append-only history keeps prefix caches hot.
|
|
1755
1932
|
// The workspace context block (AGENTS.md + ls snapshot) is appended to the
|
|
@@ -2139,7 +2316,7 @@ export class Agent {
|
|
|
2139
2316
|
* protected so current work never loses its footing.
|
|
2140
2317
|
*/
|
|
2141
2318
|
maybePrune() {
|
|
2142
|
-
const est = this.
|
|
2319
|
+
const est = this.contextSizeForBudgeting(); // floored — see #73
|
|
2143
2320
|
const budget = this.opts.contextTokenBudget;
|
|
2144
2321
|
if (!budget || est < budget * 0.6)
|
|
2145
2322
|
return 0; // prune only when it matters
|
|
@@ -2199,7 +2376,7 @@ export class Agent {
|
|
|
2199
2376
|
// for providers that omit usage
|
|
2200
2377
|
// stage 1: clip oversized old tool outputs before considering a summarize
|
|
2201
2378
|
this.maybePrune();
|
|
2202
|
-
const before = this.
|
|
2379
|
+
const before = this.contextSizeForBudgeting(); // floored — see #73
|
|
2203
2380
|
if (!force && before < this.opts.contextTokenBudget)
|
|
2204
2381
|
return;
|
|
2205
2382
|
if (!force && this.opts.autoCompact === false)
|
|
@@ -2492,12 +2669,24 @@ export class Agent {
|
|
|
2492
2669
|
async fork(fromEventId) {
|
|
2493
2670
|
const newBranch = `br${this.branchCount()}${Date.now().toString(36).slice(-4)}`;
|
|
2494
2671
|
const parentBranch = this.currentBranch;
|
|
2495
|
-
|
|
2672
|
+
// Where the fork leaves the source branch. Recorded on the event AND used
|
|
2673
|
+
// to seed the new branch, so the two can never disagree.
|
|
2674
|
+
const fromEvent = fromEventId ?? this.log.lastEventId(parentBranch);
|
|
2675
|
+
// #69: the fork event must be appended under the SOURCE branch. Appending it
|
|
2676
|
+
// under the new one gave it parent:null (nothing had been written to that
|
|
2677
|
+
// branch yet), so lineageOf() walked an empty chain and a restart rebuilt
|
|
2678
|
+
// the fork with NO history at all — the same class of bug editPromptAt had,
|
|
2679
|
+
// fixed on one path only. editPromptAt already appends under the old
|
|
2680
|
+
// branch for exactly this reason.
|
|
2681
|
+
await this.log.append("fork", this.currentSession, parentBranch, {
|
|
2496
2682
|
fromSession: this.currentSession,
|
|
2497
2683
|
fromBranch: parentBranch,
|
|
2498
|
-
fromEvent
|
|
2684
|
+
fromEvent,
|
|
2499
2685
|
newBranch,
|
|
2500
2686
|
});
|
|
2687
|
+
// Seed the new branch's parent to the point it leaves the source, so its
|
|
2688
|
+
// first event chains across instead of starting at parent:null.
|
|
2689
|
+
this.log.seedBranch(newBranch, fromEvent);
|
|
2501
2690
|
this.currentBranch = newBranch;
|
|
2502
2691
|
this.messages = [...this.messages]; // independent history copy
|
|
2503
2692
|
return { session: this.currentSession, branch: newBranch };
|
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, realpathSync } from "node:fs";
|
|
6
|
+
import { existsSync, promises as fs, readlinkSync, realpathSync } from "node:fs";
|
|
7
7
|
import path from "node:path";
|
|
8
8
|
import { discoverSkills, isValidSkillName, readSkillFile, saveSkill, SKILL_FILE, } from "./skills.js";
|
|
9
9
|
const str = (v, fallback = "") => (typeof v === "string" ? v : fallback);
|
|
@@ -16,6 +16,45 @@ function clip(s, max) {
|
|
|
16
16
|
async function readText(p) {
|
|
17
17
|
return fs.readFile(p, "utf8");
|
|
18
18
|
}
|
|
19
|
+
/**
|
|
20
|
+
* The file's dominant line ending, or null when it is mixed or empty.
|
|
21
|
+
*
|
|
22
|
+
* `null` means "do not normalise" — see the note above.
|
|
23
|
+
*/
|
|
24
|
+
export function detectEol(text) {
|
|
25
|
+
if (!text)
|
|
26
|
+
return null;
|
|
27
|
+
const crlf = (text.match(/\r\n/g) ?? []).length;
|
|
28
|
+
// a bare CR is a CR not followed by LF
|
|
29
|
+
const cr = (text.match(/\r(?!\n)/g) ?? []).length;
|
|
30
|
+
const lf = (text.match(/(?<!\r)\n/g) ?? []).length;
|
|
31
|
+
const kinds = [crlf, cr, lf].filter((n) => n > 0).length;
|
|
32
|
+
// one kind only -> unambiguous. Mixed -> null.
|
|
33
|
+
if (kinds !== 1)
|
|
34
|
+
return null;
|
|
35
|
+
if (crlf > 0)
|
|
36
|
+
return "\r\n";
|
|
37
|
+
if (cr > 0)
|
|
38
|
+
return "\r";
|
|
39
|
+
return "\n";
|
|
40
|
+
}
|
|
41
|
+
/** Split on ANY of the three line endings, dropping the terminators. */
|
|
42
|
+
export function splitEolLines(text) {
|
|
43
|
+
return text.split(/\r\n|\r|\n/);
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Re-apply `eol` to content the model supplied.
|
|
47
|
+
*
|
|
48
|
+
* Models emit `\n` essentially always, so a CRLF or CR file rewritten with LF
|
|
49
|
+
* content would otherwise be silently converted. Untouched terminators already
|
|
50
|
+
* in the right form are left alone, so this is safe to run on merged content.
|
|
51
|
+
*/
|
|
52
|
+
export function applyEol(content, eol) {
|
|
53
|
+
if (!eol || eol === "\n")
|
|
54
|
+
return content;
|
|
55
|
+
// normalise to \n first, then re-apply — avoids double-CR on "\r\n"
|
|
56
|
+
return content.replace(/\r\n|\r|\n/g, eol);
|
|
57
|
+
}
|
|
19
58
|
/**
|
|
20
59
|
* Optimistic-concurrency guard (#5). The model reads a file, thinks, then
|
|
21
60
|
* writes — and in between, a sub-agent, a background job or the operator may
|
|
@@ -60,14 +99,64 @@ export function safeJoin(cwd, p) {
|
|
|
60
99
|
if (rel.startsWith("..") || path.isAbsolute(rel)) {
|
|
61
100
|
throw new Error(`path escapes workspace: ${p}`);
|
|
62
101
|
}
|
|
63
|
-
// symlinks inside the workspace can point anywhere — resolve the real
|
|
64
|
-
//
|
|
102
|
+
// symlinks inside the workspace can point anywhere — resolve the real target
|
|
103
|
+
// and confine THAT too (path.resolve alone doesn't follow links)
|
|
104
|
+
//
|
|
105
|
+
// #74: the old code resolved `abs` and swallowed a throw, treating failure as
|
|
106
|
+
// "fine". That is exactly backwards, and the failure is the NORMAL case:
|
|
107
|
+
// `realpathSync` throws ENOENT for a file that does not exist yet, which is
|
|
108
|
+
// every write_file creating something new. So `real` stayed as the LEXICAL
|
|
109
|
+
// path — which is inside the workspace — and the symlink guard never ran.
|
|
110
|
+
//
|
|
111
|
+
// Demonstrated: with `esc -> /outside` (a real directory), safeJoin allowed
|
|
112
|
+
// `esc/pwned.txt` and write_file wrote straight through it, landing outside
|
|
113
|
+
// the workspace. The dangling case happened to fail later with ENOTDIR, which
|
|
114
|
+
// is luck, not a control.
|
|
115
|
+
//
|
|
116
|
+
// So on ENOENT/ENOTDIR, resolve the nearest EXISTING ancestor instead of
|
|
117
|
+
// giving up. That still catches the escape — the ancestor IS the symlink —
|
|
118
|
+
// while allowing genuinely new files inside the workspace.
|
|
65
119
|
let real = abs;
|
|
66
120
|
try {
|
|
67
121
|
real = realpathSync(abs);
|
|
68
122
|
}
|
|
69
|
-
catch {
|
|
70
|
-
|
|
123
|
+
catch (err) {
|
|
124
|
+
const code = err.code;
|
|
125
|
+
if (code === "ENOENT" || code === "ENOTDIR") {
|
|
126
|
+
// Walk up one component at a time. A component that is itself a SYMLINK
|
|
127
|
+
// must be resolved even when its target does not exist — realpathSync
|
|
128
|
+
// throws ENOENT for the dangling link itself, so naively walking to the
|
|
129
|
+
// first existing ancestor steps OVER the symlink and loses it. So read
|
|
130
|
+
// the link and check its target directly.
|
|
131
|
+
let probe = path.dirname(abs);
|
|
132
|
+
for (;;) {
|
|
133
|
+
try {
|
|
134
|
+
real = realpathSync(probe);
|
|
135
|
+
break;
|
|
136
|
+
}
|
|
137
|
+
catch (inner) {
|
|
138
|
+
if (inner.code !== "ENOENT")
|
|
139
|
+
break;
|
|
140
|
+
// this component is a dangling symlink: resolve where it POINTS
|
|
141
|
+
try {
|
|
142
|
+
const target = readlinkSync(probe);
|
|
143
|
+
real = path.isAbsolute(target) ? target : path.resolve(path.dirname(probe), target);
|
|
144
|
+
break;
|
|
145
|
+
}
|
|
146
|
+
catch {
|
|
147
|
+
/* not a symlink after all — keep walking */
|
|
148
|
+
}
|
|
149
|
+
const next = path.dirname(probe);
|
|
150
|
+
if (next === probe)
|
|
151
|
+
break; // reached the filesystem root
|
|
152
|
+
probe = next;
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
// keep the un-resolved tail so the comparison below sees the full path
|
|
156
|
+
const tail = path.relative(probe, abs);
|
|
157
|
+
if (tail && !tail.startsWith(".."))
|
|
158
|
+
real = path.join(real, tail);
|
|
159
|
+
}
|
|
71
160
|
}
|
|
72
161
|
const relReal = path.relative(cwd, real);
|
|
73
162
|
if (relReal.startsWith("..") || path.isAbsolute(relReal)) {
|
|
@@ -920,7 +1009,11 @@ export const TOOLS = [
|
|
|
920
1009
|
};
|
|
921
1010
|
}
|
|
922
1011
|
ctx.onFileRead?.(str(args.path));
|
|
923
|
-
|
|
1012
|
+
// #84: split on ALL THREE line endings. A CR-only (old-Mac) file used to
|
|
1013
|
+
// come back as a single line, so the model could not address lines 2..n at
|
|
1014
|
+
// all — and a CRLF file left a trailing "\r" on every line, which then
|
|
1015
|
+
// had to be stripped again by every consumer.
|
|
1016
|
+
const lines = splitEolLines(text);
|
|
924
1017
|
// #7/#4: opt-in hashline output. Default stays the historical `N| ` so
|
|
925
1018
|
// nothing that depends on it changes until the mode is chosen.
|
|
926
1019
|
const idMode = lineIdMode(args);
|
|
@@ -1006,8 +1099,25 @@ export const TOOLS = [
|
|
|
1006
1099
|
if (stale)
|
|
1007
1100
|
return stale;
|
|
1008
1101
|
await fs.mkdir(path.dirname(p), { recursive: true });
|
|
1009
|
-
|
|
1010
|
-
|
|
1102
|
+
// #84: a file's line endings belong to the FILE. Writing the model's
|
|
1103
|
+
// content verbatim silently converted a CRLF (or CR) file to LF whenever
|
|
1104
|
+
// the model rewrote it — the model never emits \r — so one logical edit
|
|
1105
|
+
// produced a whole-file diff, and any tool that normalises on EOL (git,
|
|
1106
|
+
// prettier) would rewrite it afterwards.
|
|
1107
|
+
//
|
|
1108
|
+
// So an EXISTING file's dominant EOL is re-applied. detectEol returns null
|
|
1109
|
+
// for a mixed file, which is left untouched on purpose: choosing a winner
|
|
1110
|
+
// there would itself be the silent whole-file rewrite this prevents.
|
|
1111
|
+
const before = await currentOrNull(p);
|
|
1112
|
+
const eol = before === null ? null : detectEol(before);
|
|
1113
|
+
const out = applyEol(args.content, eol);
|
|
1114
|
+
await fs.writeFile(p, out, "utf8");
|
|
1115
|
+
const converted = eol !== null && eol !== "\n" && out !== args.content;
|
|
1116
|
+
return {
|
|
1117
|
+
ok: true,
|
|
1118
|
+
result: `wrote ${p} (${out.length} bytes)` +
|
|
1119
|
+
(converted ? ` (kept ${eol === "\r\n" ? "CRLF" : "CR"} line endings)` : ""),
|
|
1120
|
+
};
|
|
1011
1121
|
},
|
|
1012
1122
|
},
|
|
1013
1123
|
{
|
|
@@ -1086,12 +1196,15 @@ export const TOOLS = [
|
|
|
1086
1196
|
const patLines = oldText.replace(/\r\n/g, "\n").split("\n");
|
|
1087
1197
|
const hits = trailingWsMatches(srcLines, patLines);
|
|
1088
1198
|
if (hits.length === 1 && patLines.length > 0) {
|
|
1089
|
-
|
|
1199
|
+
// #84: same detection as the hashline path — CR-only files are detected
|
|
1200
|
+
// too, and a MIXED file yields null and is left as-is rather than
|
|
1201
|
+
// homogenised. The old `includes("\r\n")` test called a CR-only file
|
|
1202
|
+
// "\n", so an edit to one silently converted its line endings.
|
|
1090
1203
|
const rebuilt = [
|
|
1091
1204
|
...srcLines.slice(0, hits[0]),
|
|
1092
|
-
...newText
|
|
1205
|
+
...splitEolLines(newText),
|
|
1093
1206
|
...srcLines.slice(hits[0] + patLines.length),
|
|
1094
|
-
].join(
|
|
1207
|
+
].join(detectEol(text) ?? "\n");
|
|
1095
1208
|
await fs.writeFile(p, rebuilt, "utf8");
|
|
1096
1209
|
return {
|
|
1097
1210
|
ok: true,
|
|
@@ -1147,12 +1260,10 @@ export const TOOLS = [
|
|
|
1147
1260
|
// emit \r).
|
|
1148
1261
|
const eol = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1149
1262
|
const out = hashSpan
|
|
1150
|
-
? text
|
|
1151
|
-
.split("\n")
|
|
1263
|
+
? splitEolLines(text)
|
|
1152
1264
|
.slice(0, hashSpan.start)
|
|
1153
|
-
.concat(newText
|
|
1154
|
-
.join("\n")
|
|
1155
|
-
.replace(/(?<!\r)\n/g, eol)
|
|
1265
|
+
.concat(splitEolLines(newText), splitEolLines(text).slice(hashSpan.end))
|
|
1266
|
+
.join(eol ?? "\n")
|
|
1156
1267
|
: replaceAll
|
|
1157
1268
|
? text.split(oldText).join(newText)
|
|
1158
1269
|
: text.replace(oldText, newText);
|
package/dist/master.js
CHANGED
|
@@ -229,6 +229,22 @@ function printAgentEvent(e) {
|
|
|
229
229
|
if (line)
|
|
230
230
|
console.log(`${dim(ts)} ${who} ${line}`);
|
|
231
231
|
}
|
|
232
|
+
/**
|
|
233
|
+
* Reduce an agent id to a safe single path segment.
|
|
234
|
+
*
|
|
235
|
+
* Agent ids become directory names, so anything path-like in one is an escape
|
|
236
|
+
* from the sessions root (#72). Kept in step with the per-call-site
|
|
237
|
+
* `.replace(/[^\w.-]/g, "-")` calls, and applied in `addAgent` too so the
|
|
238
|
+
* guarantee holds for every caller.
|
|
239
|
+
*/
|
|
240
|
+
export function sanitizeAgentId(id) {
|
|
241
|
+
const cleaned = String(id ?? "")
|
|
242
|
+
.replace(/[^\w.-]/g, "-")
|
|
243
|
+
// a leading dot would create a hidden dir; ".." would climb out
|
|
244
|
+
.replace(/^\.+/, "")
|
|
245
|
+
.slice(0, 60);
|
|
246
|
+
return cleaned || "agent";
|
|
247
|
+
}
|
|
232
248
|
export class Master {
|
|
233
249
|
agents = new Map();
|
|
234
250
|
/**
|
|
@@ -402,6 +418,21 @@ export class Master {
|
|
|
402
418
|
* an older one's history.
|
|
403
419
|
*/
|
|
404
420
|
async addAgent(ac, opts = {}) {
|
|
421
|
+
// #72: an agent id becomes a DIRECTORY name (sessions/<id>-<hash>), so it
|
|
422
|
+
// must be a safe path segment. Every caller sanitised its own id — the HTTP
|
|
423
|
+
// route, spawnChildFor's child name — except the ACP adapter, which passed
|
|
424
|
+
// the raw client string through, so `session/load {"sessionId":"../../x"}`
|
|
425
|
+
// created an agent logged outside <dataDir>/sessions/.
|
|
426
|
+
//
|
|
427
|
+
// Sanitised HERE as well as at the call sites: this is the one place that
|
|
428
|
+
// guarantees the property, so a future caller cannot reintroduce the escape
|
|
429
|
+
// by forgetting. A caller that already sanitised is unaffected — the
|
|
430
|
+
// transform is idempotent on a safe id.
|
|
431
|
+
const safeId = sanitizeAgentId(ac.id);
|
|
432
|
+
if (safeId !== ac.id) {
|
|
433
|
+
console.warn(`[teapot] agent id ${JSON.stringify(ac.id)} contained unsafe characters; using ${JSON.stringify(safeId)}`);
|
|
434
|
+
}
|
|
435
|
+
ac = { ...ac, id: safeId };
|
|
405
436
|
if (this.agents.has(ac.id))
|
|
406
437
|
throw new Error(`agent id already exists: ${ac.id}`);
|
|
407
438
|
// provider resolution: inline overrides > named provider > legacy llm block
|
|
@@ -1246,9 +1277,30 @@ When this task is done (or truly blocked), call finish() with a summary for @${p
|
|
|
1246
1277
|
// remove from map so a re-spawn after restart doesn't carry state
|
|
1247
1278
|
this.agents.delete(id);
|
|
1248
1279
|
});
|
|
1249
|
-
|
|
1250
|
-
|
|
1251
|
-
|
|
1280
|
+
// #79: two problems with racing a timeout against the real work.
|
|
1281
|
+
//
|
|
1282
|
+
// 1. The timer was never cleared, so it kept the event loop alive for the
|
|
1283
|
+
// FULL timeout after a fast, successful stop — a 30s handle on a stop
|
|
1284
|
+
// that took milliseconds.
|
|
1285
|
+
// 2. On timeout, the promises that HAD completed had already deleted their
|
|
1286
|
+
// agents from `this.agents`, while their peers were still mid-dispose.
|
|
1287
|
+
// `restartServer` then threw BEFORE spawning its replacement, so the
|
|
1288
|
+
// process was left serving with a half-empty agent map and no successor.
|
|
1289
|
+
//
|
|
1290
|
+
// Fixed by clearing the timer, and by reporting which agents did NOT stop
|
|
1291
|
+
// so the caller can decide rather than being left half-dead.
|
|
1292
|
+
let timer;
|
|
1293
|
+
const timeout = new Promise((_, reject) => {
|
|
1294
|
+
timer = setTimeout(() => reject(new Error("agent stop timeout")), timeoutMs);
|
|
1295
|
+
});
|
|
1296
|
+
try {
|
|
1297
|
+
await Promise.race([Promise.all(stopPromises), timeout]);
|
|
1298
|
+
console.log("[teapot] all agents stopped");
|
|
1299
|
+
}
|
|
1300
|
+
finally {
|
|
1301
|
+
if (timer)
|
|
1302
|
+
clearTimeout(timer);
|
|
1303
|
+
}
|
|
1252
1304
|
}
|
|
1253
1305
|
/** Restart the server process (spawns a new master, keeps this one alive until ready). */
|
|
1254
1306
|
async restartServer() {
|