codecartographer-pi 0.22.1 → 0.22.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/.codecarto/workflow/scaffold-version.yaml +1 -1
- package/README.md +1 -1
- package/dist/core/broadside.d.ts +68 -0
- package/dist/core/broadside.js +244 -34
- package/dist/extensions/codecarto/completions.d.ts +18 -0
- package/dist/extensions/codecarto/completions.js +36 -0
- package/dist/extensions/codecarto/index.js +17 -39
- package/dist/mcp-server/server.js +22 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -396,7 +396,7 @@ codecarto_broadside {cwd, action: "status"} # what is in fligh
|
|
|
396
396
|
codecarto_broadside {cwd, action: "collect"} # poll, save, synthesize, triage
|
|
397
397
|
```
|
|
398
398
|
|
|
399
|
-
Submit and collect are separate because batch jobs routinely take tens of minutes; collect is resumable and picks up whatever is still in flight (`wait_seconds: 0`, the default, polls once and returns). Submit prices the run from the collected file sizes against the model's live per-token pricing (cached 24h) and refuses when the estimate exceeds `max_cost` — $1.00 unless the config or the call sets another value, `0` for no limit — unless `force: true` is passed — a pre-flight estimate, not a runtime stop. Actual spend lands in each run's `run-meta.json`.
|
|
399
|
+
Submit and collect are separate because batch jobs routinely take tens of minutes; collect is resumable and picks up whatever is still in flight (`wait_seconds: 0`, the default, polls once and returns), and two collects on one run — a retried tool call, a second session — never pay for the synthesis, triage, or truncation retry twice: each is claimed in the run's state before it is submitted, and a collect whose client has gone away stops polling and submits nothing further. Submit prices the run from the collected file sizes against the model's live per-token pricing (cached 24h) and refuses when the estimate exceeds `max_cost` — $1.00 unless the config or the call sets another value, `0` for no limit — unless `force: true` is passed — a pre-flight estimate, not a runtime stop. Actual spend lands in each run's `run-meta.json`.
|
|
400
400
|
|
|
401
401
|
Repository defaults live in `.codecarto/broadside/config.yaml` (`model`, `api_key`, `default_lenses`, `max_cost`, `pricing` overrides, `lens_models`, `incremental`, `retry_truncated`, `include_synthesis`, `include_triage`, `wait_seconds`); an explicit tool parameter always wins. `lens_models` routes individual lenses to their own batch model — a stronger model changes security and defect findings far more than it changes an architecture map — and each override is priced, capability-checked, and clamped exactly like the default, with the estimate broken out per lens so a mixed-model run cannot be approved without seeing which lens costs what. CodeCartographer ships no stronger default: which model earns its price depends on your repository and budget, so compare candidates with the `models` action and choose — for one run with the `model` and `lens_models` parameters (Pi: `--model=ID`, `--lens-model=LENS:ID`), or for the repository in `config.yaml`. The `models` listing is advisory: OpenRouter's catalog returns a `:batch` id for some models its Batch API then refuses (`does not have a :batch endpoint`), at no cost, and nothing in the catalog tells them apart — so the listing tags the ids this repository's own submits have seen accepted or refused, and a refused lens says why in the submit report. `codecarto_skill {cwd, name: "broadside"}` returns the reading guide for a completed run, and unlike post-pipeline skills it is not gated on a finished pipeline.
|
|
402
402
|
|
package/dist/core/broadside.d.ts
CHANGED
|
@@ -230,7 +230,24 @@ export type BroadsideTriageEntry = {
|
|
|
230
230
|
batchId?: string;
|
|
231
231
|
status: "pending" | "submitted" | "completed" | "failed";
|
|
232
232
|
cost?: number;
|
|
233
|
+
error?: string;
|
|
233
234
|
};
|
|
235
|
+
/** The truncation retry pass of one run: one batch per model (#206). */
|
|
236
|
+
export type BroadsideRetryEntry = {
|
|
237
|
+
status: "submitted" | "completed" | "failed";
|
|
238
|
+
batches: Array<{
|
|
239
|
+
model: string;
|
|
240
|
+
batchId: string;
|
|
241
|
+
}>;
|
|
242
|
+
/** When the owning collect claimed the pass (#322). */
|
|
243
|
+
claimedAt: string;
|
|
244
|
+
};
|
|
245
|
+
/**
|
|
246
|
+
* The parts of a run that cost money to submit and that exactly one collect
|
|
247
|
+
* may own: the two post-passes and the truncation retry (#322).
|
|
248
|
+
*/
|
|
249
|
+
export type BroadsideRunSlot = "synthesis" | "triage" | "retry";
|
|
250
|
+
export declare const BROADSIDE_RUN_SLOTS: readonly BroadsideRunSlot[];
|
|
234
251
|
export type BroadsideRun = {
|
|
235
252
|
id: string;
|
|
236
253
|
createdAt: string;
|
|
@@ -241,6 +258,11 @@ export type BroadsideRun = {
|
|
|
241
258
|
batches: Partial<Record<BroadsideLensId, BroadsideBatchEntry>>;
|
|
242
259
|
synthesis: BroadsideSynthesisEntry;
|
|
243
260
|
triage: BroadsideTriageEntry;
|
|
261
|
+
/**
|
|
262
|
+
* The truncation retry pass (#133), recorded so that two collects on one
|
|
263
|
+
* run cannot both submit it (#322). Absent until a collect claims it.
|
|
264
|
+
*/
|
|
265
|
+
retry?: BroadsideRetryEntry;
|
|
244
266
|
totalCost?: number;
|
|
245
267
|
pricing?: ModelPricing;
|
|
246
268
|
maxCost?: number;
|
|
@@ -438,6 +460,8 @@ export type BroadsideCollectResult = {
|
|
|
438
460
|
truncatedCount: number;
|
|
439
461
|
/** Truncated slices recovered by the automatic re-submit pass (#133). */
|
|
440
462
|
retriedCount: number;
|
|
463
|
+
/** Another collect on this run owns the retry pass; its result lands on a later collect (#322). */
|
|
464
|
+
retryElsewhere?: boolean;
|
|
441
465
|
lensOutcomes: Partial<Record<BroadsideLensId, {
|
|
442
466
|
status: string;
|
|
443
467
|
cost?: number;
|
|
@@ -555,6 +579,35 @@ export declare function updateBroadsideStateAtomically(broadsideDir: string, mut
|
|
|
555
579
|
* restored the next time its own operation checkpoints.
|
|
556
580
|
*/
|
|
557
581
|
export declare function persistBroadsideRun(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
|
|
582
|
+
/**
|
|
583
|
+
* Record a collect's view of its run, keeping whatever is further along on
|
|
584
|
+
* disk (#322).
|
|
585
|
+
*
|
|
586
|
+
* Two collects on one run each hold the run in memory and each used to write
|
|
587
|
+
* the whole thing back, so the last writer replaced the other's post-pass
|
|
588
|
+
* entries with its own — and both had submitted their own post-passes, since
|
|
589
|
+
* each decided from the copy it loaded at entry. This writer merges slot by
|
|
590
|
+
* slot: a post-pass or retry entry that is further along on disk (claimed
|
|
591
|
+
* over pending, submitted over claimed, settled over submitted) wins and is
|
|
592
|
+
* copied into `run`, so the caller reports what is true; a lens entry never
|
|
593
|
+
* goes backwards from terminal to polling. A tie keeps this collect's copy,
|
|
594
|
+
* so the collect that settled a pass records its cost. Submitting is guarded
|
|
595
|
+
* separately by {@link claimRunSlot}.
|
|
596
|
+
*/
|
|
597
|
+
export declare function persistBroadsideRunMerging(broadsideDir: string, run: BroadsideRun): Promise<BroadsideStateFile>;
|
|
598
|
+
/**
|
|
599
|
+
* Claim one spending slot of a run for this collect (#322).
|
|
600
|
+
*
|
|
601
|
+
* Read-modify-write under the state lock: if the slot on disk is still
|
|
602
|
+
* unclaimed (`pending`, or absent for the retry), it is marked `submitted`
|
|
603
|
+
* with no batch id *before* any network call and `true` comes back — this
|
|
604
|
+
* collect owns it and may submit. Otherwise another collect got there first:
|
|
605
|
+
* its entry is copied into `run` and `false` comes back. An adopted entry
|
|
606
|
+
* with a batch id can be polled (polling is idempotent); one without an id
|
|
607
|
+
* is a claim whose owner has not recorded the id yet, and is reported as in
|
|
608
|
+
* flight elsewhere.
|
|
609
|
+
*/
|
|
610
|
+
export declare function claimRunSlot(broadsideDir: string, run: BroadsideRun, slot: BroadsideRunSlot): Promise<boolean>;
|
|
558
611
|
export declare function loadBroadsideConfig(broadsideDir: string): Promise<BroadsideConfig>;
|
|
559
612
|
/** The shipped defaults: what an absent config.yaml means. */
|
|
560
613
|
export declare function defaultBroadsideConfig(): BroadsideConfig;
|
|
@@ -624,6 +677,12 @@ export declare function pollBatchUntilTerminal(batchId: string, apiKey: string,
|
|
|
624
677
|
onStatus?: (status: string, counts: Record<string, unknown>) => void;
|
|
625
678
|
fetcher?: FetchLike;
|
|
626
679
|
pollIntervalMs?: number;
|
|
680
|
+
/**
|
|
681
|
+
* Stops polling early with the same synthetic `timeout` a spent budget
|
|
682
|
+
* returns: the batch keeps running server-side and a later collect
|
|
683
|
+
* claims it. The MCP server aborts when its client disconnects (#322).
|
|
684
|
+
*/
|
|
685
|
+
signal?: AbortSignal;
|
|
627
686
|
}): Promise<Record<string, unknown>>;
|
|
628
687
|
/**
|
|
629
688
|
* Poll several batch ids in parallel against one shared deadline. Collect
|
|
@@ -639,6 +698,7 @@ export declare function pollBatchesConcurrently(entries: Array<{
|
|
|
639
698
|
deadlineMs?: number;
|
|
640
699
|
fetcher?: FetchLike;
|
|
641
700
|
pollIntervalMs?: number;
|
|
701
|
+
signal?: AbortSignal;
|
|
642
702
|
onStatus?: (lensId: string, status: string, counts: Record<string, unknown>) => void;
|
|
643
703
|
}): Promise<Map<string, Record<string, unknown>>>;
|
|
644
704
|
export declare function runBroadsideSubmit(cwd: string, apiKey: string, opts?: {
|
|
@@ -713,6 +773,14 @@ export declare function runBroadsideCollect(cwd: string, apiKey: string, opts?:
|
|
|
713
773
|
* once a newer submit existed (#268). `status` lists the ids.
|
|
714
774
|
*/
|
|
715
775
|
runId?: string;
|
|
776
|
+
/**
|
|
777
|
+
* Stops polling and submits nothing further once fired; what was
|
|
778
|
+
* already submitted keeps running server-side for a later collect to
|
|
779
|
+
* claim. The MCP server fires it when its client disconnects (#322).
|
|
780
|
+
*/
|
|
781
|
+
signal?: AbortSignal;
|
|
782
|
+
/** Poll cadence override; tests drive the loop faster than 15 s. */
|
|
783
|
+
pollIntervalMs?: number;
|
|
716
784
|
}): Promise<BroadsideCollectResult>;
|
|
717
785
|
export declare function runBroadsideStatus(cwd: string): Promise<{
|
|
718
786
|
state: BroadsideStateFile;
|
package/dist/core/broadside.js
CHANGED
|
@@ -140,6 +140,7 @@ export function retryReasoningFor(original) {
|
|
|
140
140
|
const { max_tokens: _cap, effort: _effort, ...rest } = original ?? {};
|
|
141
141
|
return { ...rest, effort: "low" };
|
|
142
142
|
}
|
|
143
|
+
export const BROADSIDE_RUN_SLOTS = ["synthesis", "triage", "retry"];
|
|
143
144
|
/**
|
|
144
145
|
* OpenRouter rejected the API key (HTTP 401/403). Thrown from the catalog
|
|
145
146
|
* lookup rather than swallowed into "could not price" or a silent built-in
|
|
@@ -1534,6 +1535,110 @@ export async function persistBroadsideRun(broadsideDir, run) {
|
|
|
1534
1535
|
state.runs[index] = run;
|
|
1535
1536
|
});
|
|
1536
1537
|
}
|
|
1538
|
+
/** Where a lens batch entry stands, for keeping the more advanced of two. */
|
|
1539
|
+
function batchEntryRank(entry) {
|
|
1540
|
+
if (!entry)
|
|
1541
|
+
return -1;
|
|
1542
|
+
if (BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status))
|
|
1543
|
+
return 2;
|
|
1544
|
+
if (entry.batchId)
|
|
1545
|
+
return 1;
|
|
1546
|
+
return 0;
|
|
1547
|
+
}
|
|
1548
|
+
/** Where a post-pass entry stands: unclaimed, claimed, submitted, settled. */
|
|
1549
|
+
function passEntryRank(entry) {
|
|
1550
|
+
if (!entry || entry.status === "pending")
|
|
1551
|
+
return 0;
|
|
1552
|
+
if (entry.status === "submitted")
|
|
1553
|
+
return entry.batchId ? 2 : 1;
|
|
1554
|
+
return 3;
|
|
1555
|
+
}
|
|
1556
|
+
/** Where the retry pass stands: absent, claimed, submitted, settled. */
|
|
1557
|
+
function retryEntryRank(entry) {
|
|
1558
|
+
if (!entry)
|
|
1559
|
+
return 0;
|
|
1560
|
+
if (entry.status === "submitted")
|
|
1561
|
+
return entry.batches.length > 0 ? 2 : 1;
|
|
1562
|
+
return 3;
|
|
1563
|
+
}
|
|
1564
|
+
/**
|
|
1565
|
+
* Record a collect's view of its run, keeping whatever is further along on
|
|
1566
|
+
* disk (#322).
|
|
1567
|
+
*
|
|
1568
|
+
* Two collects on one run each hold the run in memory and each used to write
|
|
1569
|
+
* the whole thing back, so the last writer replaced the other's post-pass
|
|
1570
|
+
* entries with its own — and both had submitted their own post-passes, since
|
|
1571
|
+
* each decided from the copy it loaded at entry. This writer merges slot by
|
|
1572
|
+
* slot: a post-pass or retry entry that is further along on disk (claimed
|
|
1573
|
+
* over pending, submitted over claimed, settled over submitted) wins and is
|
|
1574
|
+
* copied into `run`, so the caller reports what is true; a lens entry never
|
|
1575
|
+
* goes backwards from terminal to polling. A tie keeps this collect's copy,
|
|
1576
|
+
* so the collect that settled a pass records its cost. Submitting is guarded
|
|
1577
|
+
* separately by {@link claimRunSlot}.
|
|
1578
|
+
*/
|
|
1579
|
+
export async function persistBroadsideRunMerging(broadsideDir, run) {
|
|
1580
|
+
return updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1581
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1582
|
+
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
1583
|
+
if (onDisk) {
|
|
1584
|
+
if (passEntryRank(onDisk.synthesis) > passEntryRank(run.synthesis))
|
|
1585
|
+
run.synthesis = onDisk.synthesis;
|
|
1586
|
+
if (passEntryRank(onDisk.triage) > passEntryRank(run.triage))
|
|
1587
|
+
run.triage = onDisk.triage;
|
|
1588
|
+
if (retryEntryRank(onDisk.retry) > retryEntryRank(run.retry))
|
|
1589
|
+
run.retry = onDisk.retry;
|
|
1590
|
+
for (const [lensId, theirs] of Object.entries(onDisk.batches)) {
|
|
1591
|
+
if (theirs && batchEntryRank(theirs) > batchEntryRank(run.batches[lensId]))
|
|
1592
|
+
run.batches[lensId] = theirs;
|
|
1593
|
+
}
|
|
1594
|
+
}
|
|
1595
|
+
if (index === -1)
|
|
1596
|
+
state.runs.push(run);
|
|
1597
|
+
else
|
|
1598
|
+
state.runs[index] = run;
|
|
1599
|
+
});
|
|
1600
|
+
}
|
|
1601
|
+
/**
|
|
1602
|
+
* Claim one spending slot of a run for this collect (#322).
|
|
1603
|
+
*
|
|
1604
|
+
* Read-modify-write under the state lock: if the slot on disk is still
|
|
1605
|
+
* unclaimed (`pending`, or absent for the retry), it is marked `submitted`
|
|
1606
|
+
* with no batch id *before* any network call and `true` comes back — this
|
|
1607
|
+
* collect owns it and may submit. Otherwise another collect got there first:
|
|
1608
|
+
* its entry is copied into `run` and `false` comes back. An adopted entry
|
|
1609
|
+
* with a batch id can be polled (polling is idempotent); one without an id
|
|
1610
|
+
* is a claim whose owner has not recorded the id yet, and is reported as in
|
|
1611
|
+
* flight elsewhere.
|
|
1612
|
+
*/
|
|
1613
|
+
export async function claimRunSlot(broadsideDir, run, slot) {
|
|
1614
|
+
let owned = false;
|
|
1615
|
+
const claimedAt = new Date().toISOString();
|
|
1616
|
+
await updateBroadsideStateAtomically(broadsideDir, (state) => {
|
|
1617
|
+
const index = state.runs.findIndex((candidate) => candidate.id === run.id);
|
|
1618
|
+
const onDisk = index === -1 ? undefined : state.runs[index];
|
|
1619
|
+
const theirs = onDisk?.[slot];
|
|
1620
|
+
const unclaimed = slot === "retry" ? theirs === undefined : theirs?.status === "pending";
|
|
1621
|
+
if (onDisk && !unclaimed) {
|
|
1622
|
+
run[slot] = theirs;
|
|
1623
|
+
owned = false;
|
|
1624
|
+
return;
|
|
1625
|
+
}
|
|
1626
|
+
owned = true;
|
|
1627
|
+
if (slot === "retry") {
|
|
1628
|
+
run.retry = { status: "submitted", batches: [], claimedAt };
|
|
1629
|
+
}
|
|
1630
|
+
else {
|
|
1631
|
+
run[slot] = { ...run[slot], status: "submitted", batchId: undefined };
|
|
1632
|
+
}
|
|
1633
|
+
if (!onDisk) {
|
|
1634
|
+
state.runs.push(run);
|
|
1635
|
+
}
|
|
1636
|
+
else {
|
|
1637
|
+
onDisk[slot] = run[slot];
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1640
|
+
return owned;
|
|
1641
|
+
}
|
|
1537
1642
|
/** Read a `reasoning:` block from config.yaml, ignoring anything malformed. */
|
|
1538
1643
|
function parseReasoningConfig(raw) {
|
|
1539
1644
|
if (raw === false)
|
|
@@ -2025,6 +2130,8 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
2025
2130
|
...(lastError && sawBatch && { last_error: lastError }),
|
|
2026
2131
|
});
|
|
2027
2132
|
for (;;) {
|
|
2133
|
+
if (opts.signal?.aborted)
|
|
2134
|
+
return { ...timedOut(), aborted: true };
|
|
2028
2135
|
let batch;
|
|
2029
2136
|
try {
|
|
2030
2137
|
batch = await fetchBatch(batchId, apiKey, fetcher);
|
|
@@ -2057,8 +2164,26 @@ export async function pollBatchUntilTerminal(batchId, apiKey, opts = {}) {
|
|
|
2057
2164
|
return batch;
|
|
2058
2165
|
if (Date.now() >= deadline)
|
|
2059
2166
|
return timedOut();
|
|
2060
|
-
await
|
|
2061
|
-
}
|
|
2167
|
+
await sleepUnlessAborted(intervalMs, opts.signal);
|
|
2168
|
+
}
|
|
2169
|
+
}
|
|
2170
|
+
/** Sleep, but wake at once when the signal fires so an abort is not a poll interval late. */
|
|
2171
|
+
function sleepUnlessAborted(ms, signal) {
|
|
2172
|
+
if (!signal)
|
|
2173
|
+
return sleep(ms);
|
|
2174
|
+
if (signal.aborted)
|
|
2175
|
+
return Promise.resolve();
|
|
2176
|
+
return new Promise((resolve) => {
|
|
2177
|
+
const timer = setTimeout(() => {
|
|
2178
|
+
signal.removeEventListener("abort", onAbort);
|
|
2179
|
+
resolve();
|
|
2180
|
+
}, ms);
|
|
2181
|
+
const onAbort = () => {
|
|
2182
|
+
clearTimeout(timer);
|
|
2183
|
+
resolve();
|
|
2184
|
+
};
|
|
2185
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
2186
|
+
});
|
|
2062
2187
|
}
|
|
2063
2188
|
/**
|
|
2064
2189
|
* Poll several batch ids in parallel against one shared deadline. Collect
|
|
@@ -2075,6 +2200,7 @@ export async function pollBatchesConcurrently(entries, apiKey, opts = {}) {
|
|
|
2075
2200
|
deadlineMs,
|
|
2076
2201
|
fetcher: opts.fetcher,
|
|
2077
2202
|
pollIntervalMs: opts.pollIntervalMs,
|
|
2203
|
+
signal: opts.signal,
|
|
2078
2204
|
onStatus: (status, counts) => opts.onStatus?.(lensId, status, counts),
|
|
2079
2205
|
});
|
|
2080
2206
|
results.set(batchId, batch);
|
|
@@ -2606,6 +2732,12 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2606
2732
|
}
|
|
2607
2733
|
const runDir = join(broadsideDir, run.outputDir);
|
|
2608
2734
|
await mkdir(runDir, { recursive: true });
|
|
2735
|
+
// The spending slots this collect has claimed (#322); only a claimed slot
|
|
2736
|
+
// is ever submitted from here. Every write-back merges with the file, so a
|
|
2737
|
+
// slot another collect has moved further along is never overwritten.
|
|
2738
|
+
const owned = new Set();
|
|
2739
|
+
const persist = () => persistBroadsideRunMerging(broadsideDir, run);
|
|
2740
|
+
const aborted = () => opts.signal?.aborted === true;
|
|
2609
2741
|
const deadline = Date.now() + (opts.waitMs ?? BROADSIDE_DEFAULT_POLL_BUDGET_MS);
|
|
2610
2742
|
let totalCost = 0;
|
|
2611
2743
|
let resultCount = 0;
|
|
@@ -2633,6 +2765,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2633
2765
|
const polled = await pollBatchesConcurrently(inFlight, apiKey, {
|
|
2634
2766
|
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2635
2767
|
fetcher: opts.fetcher,
|
|
2768
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
2769
|
+
signal: opts.signal,
|
|
2636
2770
|
onStatus: opts.onStatus,
|
|
2637
2771
|
});
|
|
2638
2772
|
for (const { lensId } of inFlight) {
|
|
@@ -2682,7 +2816,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2682
2816
|
const error = explainBatchError(batch.error);
|
|
2683
2817
|
lensOutcomes[lensId] = { status, cost: entry.cost, resultCount: entry.resultCount, ...(error && { error }) };
|
|
2684
2818
|
}
|
|
2685
|
-
await
|
|
2819
|
+
await persist();
|
|
2686
2820
|
}
|
|
2687
2821
|
// #133: re-submit truncated slices once with a bumped output cap and low
|
|
2688
2822
|
// reasoning effort. Batch requests are pure, so re-running is always safe;
|
|
@@ -2700,7 +2834,36 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2700
2834
|
// lens pass, still present here (#206). Grouping also keeps the retry to
|
|
2701
2835
|
// one job per model against OpenRouter's 16-concurrent-job quota.
|
|
2702
2836
|
let retriedCount = 0;
|
|
2703
|
-
|
|
2837
|
+
let retryElsewhere = false;
|
|
2838
|
+
// A collect that polled nothing — every lens already terminal — still owes
|
|
2839
|
+
// the retry if the collect that saved the results never got to it (it
|
|
2840
|
+
// died, or its client did: #322). Read the saved results back and let the
|
|
2841
|
+
// claim decide; a recovered slice re-parses clean, so this costs nothing
|
|
2842
|
+
// once the retry has run.
|
|
2843
|
+
if (opts.retryTruncated !== false && allLensResults.length === 0 && !aborted()) {
|
|
2844
|
+
const everyLensTerminal = run.lenses.every((lensId) => {
|
|
2845
|
+
const entry = run.batches[lensId];
|
|
2846
|
+
return entry && BROADSIDE_TERMINAL_ENTRY_STATUSES.includes(entry.status);
|
|
2847
|
+
});
|
|
2848
|
+
if (everyLensTerminal) {
|
|
2849
|
+
const restored = await loadSavedLensResults(runDir, run.lenses);
|
|
2850
|
+
if (restored.some((s) => s.truncated)) {
|
|
2851
|
+
allLensResults.push(...restored);
|
|
2852
|
+
truncatedCount = restored.filter((s) => s.truncated).length;
|
|
2853
|
+
}
|
|
2854
|
+
}
|
|
2855
|
+
}
|
|
2856
|
+
if (opts.retryTruncated !== false && truncatedCount > 0 && !aborted()) {
|
|
2857
|
+
// Claim the pass before spending: a second collect on this run finds the
|
|
2858
|
+
// claim and leaves the retry to the first (#322). A retry another
|
|
2859
|
+
// collect has already settled is not run again — its truncation is
|
|
2860
|
+
// what it is.
|
|
2861
|
+
if (await claimRunSlot(broadsideDir, run, "retry"))
|
|
2862
|
+
owned.add("retry");
|
|
2863
|
+
else if (run.retry?.status === "submitted")
|
|
2864
|
+
retryElsewhere = true;
|
|
2865
|
+
}
|
|
2866
|
+
if (opts.retryTruncated !== false && truncatedCount > 0 && owned.has("retry")) {
|
|
2704
2867
|
const requestsByCustomId = await loadStoredRequests(runDir);
|
|
2705
2868
|
const byModel = new Map();
|
|
2706
2869
|
for (const stored of allLensResults) {
|
|
@@ -2730,6 +2893,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2730
2893
|
// Submit every group, then poll whatever was accepted, together.
|
|
2731
2894
|
const submitted = [];
|
|
2732
2895
|
for (const [model, group] of byModel) {
|
|
2896
|
+
if (aborted())
|
|
2897
|
+
break;
|
|
2733
2898
|
try {
|
|
2734
2899
|
const { batchId, error } = await submitBatch(group.requests, apiKey, opts.fetcher, model);
|
|
2735
2900
|
if (!error && batchId)
|
|
@@ -2740,6 +2905,15 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2740
2905
|
// truncated results in place — nothing is lost.
|
|
2741
2906
|
}
|
|
2742
2907
|
}
|
|
2908
|
+
// Record the ids under the claim so a later collect can see what was
|
|
2909
|
+
// paid for, even if this one never returns. No group at all means every
|
|
2910
|
+
// truncated slice was already at its model's ceiling: nothing to retry.
|
|
2911
|
+
run.retry = {
|
|
2912
|
+
...run.retry,
|
|
2913
|
+
batches: submitted,
|
|
2914
|
+
status: submitted.length > 0 ? "submitted" : byModel.size === 0 ? "completed" : "failed",
|
|
2915
|
+
};
|
|
2916
|
+
await persist();
|
|
2743
2917
|
const polled = await pollBatchesConcurrently(submitted.map(({ model, batchId }) => ({ lensId: `retry:${model}`, batchId })), apiKey, {
|
|
2744
2918
|
// Share the caller's deadline. Each of these polls used to start a
|
|
2745
2919
|
// fresh 25-minute budget, so `wait_seconds` bounded only the lens
|
|
@@ -2747,6 +2921,8 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2747
2921
|
// minutes.
|
|
2748
2922
|
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2749
2923
|
fetcher: opts.fetcher,
|
|
2924
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
2925
|
+
signal: opts.signal,
|
|
2750
2926
|
onStatus: opts.onStatus,
|
|
2751
2927
|
});
|
|
2752
2928
|
for (const { model, batchId } of submitted) {
|
|
@@ -2774,13 +2950,17 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2774
2950
|
retriedCount += 1;
|
|
2775
2951
|
}
|
|
2776
2952
|
}
|
|
2953
|
+
// Every retry batch reached a terminal status, or the poll ran out.
|
|
2954
|
+
if (submitted.length > 0 && submitted.every(({ batchId }) => polled.get(batchId)?.status === "completed")) {
|
|
2955
|
+
run.retry = { ...run.retry, status: "completed" };
|
|
2956
|
+
}
|
|
2777
2957
|
truncatedCount = allLensResults.filter((s) => s.truncated).length;
|
|
2778
2958
|
for (const [lensId, outcome] of Object.entries(lensOutcomes)) {
|
|
2779
2959
|
if (outcome.truncated !== undefined) {
|
|
2780
2960
|
outcome.truncated = allLensResults.filter((s) => s.lensId === lensId && s.truncated).length;
|
|
2781
2961
|
}
|
|
2782
2962
|
}
|
|
2783
|
-
await
|
|
2963
|
+
await persist();
|
|
2784
2964
|
}
|
|
2785
2965
|
// Synthesis + triage: cross-lens post-passes, only after every lens batch
|
|
2786
2966
|
// is terminal. Triage turns the leads into a prioritized work order.
|
|
@@ -2819,22 +2999,27 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2819
2999
|
// Both post-passes consume the same findings; they run as two
|
|
2820
3000
|
// batches (different response_format schemas cannot share one)
|
|
2821
3001
|
// submitted together and polled in turn.
|
|
2822
|
-
|
|
2823
|
-
|
|
2824
|
-
|
|
2825
|
-
|
|
2826
|
-
|
|
2827
|
-
|
|
2828
|
-
|
|
2829
|
-
|
|
2830
|
-
|
|
2831
|
-
|
|
2832
|
-
|
|
2833
|
-
|
|
2834
|
-
|
|
2835
|
-
|
|
2836
|
-
|
|
2837
|
-
|
|
3002
|
+
// Claim each wanted, still-pending pass before building its request:
|
|
3003
|
+
// a second collect on this run adopts the first one's entry instead
|
|
3004
|
+
// of submitting its own (#322). An abort submits nothing further.
|
|
3005
|
+
const passes = [];
|
|
3006
|
+
for (const kind of ["synthesis", "triage"]) {
|
|
3007
|
+
const want = kind === "synthesis" ? wantSynthesis : wantTriage;
|
|
3008
|
+
if (!want || aborted())
|
|
3009
|
+
continue;
|
|
3010
|
+
if ((kind === "synthesis" ? run.synthesis : run.triage).status !== "pending")
|
|
3011
|
+
continue;
|
|
3012
|
+
if (!(await claimRunSlot(broadsideDir, run, kind)))
|
|
3013
|
+
continue;
|
|
3014
|
+
owned.add(kind);
|
|
3015
|
+
passes.push({
|
|
3016
|
+
kind,
|
|
3017
|
+
request: kind === "synthesis"
|
|
3018
|
+
? buildSynthesisRequest(findingsText, truncatedNote, run.model)
|
|
3019
|
+
: buildTriageRequest(findingsText, truncatedNote, run.model),
|
|
3020
|
+
entry: kind === "synthesis" ? run.synthesis : run.triage,
|
|
3021
|
+
});
|
|
3022
|
+
}
|
|
2838
3023
|
const submitted = new Map();
|
|
2839
3024
|
// A pass can be left at "submitted" when an earlier collect returned
|
|
2840
3025
|
// before its batch reached a terminal status — the batch still runs
|
|
@@ -2869,16 +3054,22 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2869
3054
|
pass.entry.status = "failed";
|
|
2870
3055
|
}
|
|
2871
3056
|
}));
|
|
2872
|
-
await
|
|
3057
|
+
await persist();
|
|
3058
|
+
// Poll both passes together against the shared deadline. Polled in
|
|
3059
|
+
// turn, the first pass could spend the whole budget and leave the
|
|
3060
|
+
// second a single poll (0.22.1 live run: triage settled, synthesis
|
|
3061
|
+
// left running though it had been submitted at the same moment).
|
|
3062
|
+
// A pass whose poll runs out stays `submitted`, so the batch is
|
|
3063
|
+
// already paid for and a later collect claims its result.
|
|
3064
|
+
const polledPasses = await pollBatchesConcurrently([...submitted.values()].map(({ batchId, pass }) => ({ lensId: pass.kind, batchId })), apiKey, {
|
|
3065
|
+
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
3066
|
+
fetcher: opts.fetcher,
|
|
3067
|
+
pollIntervalMs: opts.pollIntervalMs,
|
|
3068
|
+
signal: opts.signal,
|
|
3069
|
+
onStatus: opts.onStatus,
|
|
3070
|
+
});
|
|
2873
3071
|
for (const { batchId, pass } of submitted.values()) {
|
|
2874
|
-
const batch =
|
|
2875
|
-
// Shares the caller's deadline, as the retry poll above does.
|
|
2876
|
-
// A pass whose poll runs out stays `submitted`, so the batch
|
|
2877
|
-
// is already paid for and a later collect claims its result.
|
|
2878
|
-
deadlineMs: Math.max(0, deadline - Date.now()),
|
|
2879
|
-
onStatus: (status, counts) => opts.onStatus?.(pass.kind, status, counts),
|
|
2880
|
-
fetcher: opts.fetcher,
|
|
2881
|
-
});
|
|
3072
|
+
const batch = polledPasses.get(batchId) ?? { id: batchId, status: "timeout" };
|
|
2882
3073
|
if (batch.status === "completed") {
|
|
2883
3074
|
const usage = (batch.usage ?? {});
|
|
2884
3075
|
const cost = typeof usage.cost === "number" ? usage.cost : undefined;
|
|
@@ -2911,7 +3102,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2911
3102
|
// A "timeout" is deliberately left at "submitted": the batch is
|
|
2912
3103
|
// still running server-side and has already been paid for, so a
|
|
2913
3104
|
// later collect should claim its result rather than discard it.
|
|
2914
|
-
await
|
|
3105
|
+
await persist();
|
|
2915
3106
|
}
|
|
2916
3107
|
}
|
|
2917
3108
|
}
|
|
@@ -2921,7 +3112,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2921
3112
|
});
|
|
2922
3113
|
run.status = terminal ? (resultCount > 0 ? "completed" : "failed") : "partial";
|
|
2923
3114
|
run.totalCost = totalCost;
|
|
2924
|
-
await
|
|
3115
|
+
await persist();
|
|
2925
3116
|
await writeFile(join(runDir, "run-meta.json"), `${JSON.stringify({
|
|
2926
3117
|
experimental: true,
|
|
2927
3118
|
method: "Broad-Side (OpenRouter Batch API)",
|
|
@@ -2953,6 +3144,7 @@ export async function runBroadsideCollect(cwd, apiKey, opts = {}) {
|
|
|
2953
3144
|
resultCount,
|
|
2954
3145
|
truncatedCount,
|
|
2955
3146
|
retriedCount,
|
|
3147
|
+
...(retryElsewhere && { retryElsewhere: true }),
|
|
2956
3148
|
lensOutcomes,
|
|
2957
3149
|
synthesis: run.synthesis,
|
|
2958
3150
|
triage: run.triage,
|
|
@@ -3220,9 +3412,24 @@ export function collectResultText(result) {
|
|
|
3220
3412
|
if (result.retriedCount > 0) {
|
|
3221
3413
|
lines.push(` ↻ ${result.retriedCount} truncated result(s) recovered by re-submission with a doubled output cap.`);
|
|
3222
3414
|
}
|
|
3415
|
+
if (result.retryElsewhere) {
|
|
3416
|
+
lines.push(" ↻ The truncation retry is in flight in another collect on this run; collect again for its result.");
|
|
3417
|
+
}
|
|
3223
3418
|
if (result.truncatedCount > 0) {
|
|
3224
3419
|
lines.push(` ⚠ ${result.truncatedCount} result(s) still truncated after retry — their modules are unscouted, not clean.`);
|
|
3225
3420
|
}
|
|
3421
|
+
// A pass still in flight or retired must appear: a run reported
|
|
3422
|
+
// "completed" with no synthesis line read as "no synthesis was run",
|
|
3423
|
+
// when the batch was running and a later collect would have claimed it
|
|
3424
|
+
// (0.22.1 live run — the collect's wait ran out during the pass).
|
|
3425
|
+
const passInFlight = (kind, entry) => {
|
|
3426
|
+
if (entry.status === "submitted") {
|
|
3427
|
+
lines.push(` ${kind}: ${entry.batchId ? "still running" : "in flight in another collect"} — collect again for its result.`);
|
|
3428
|
+
}
|
|
3429
|
+
else if (entry.status === "failed") {
|
|
3430
|
+
lines.push(` ${kind}: failed${entry.error ? ` — ${explainBatchError(entry.error)}` : ""}`);
|
|
3431
|
+
}
|
|
3432
|
+
};
|
|
3226
3433
|
if (result.synthesis.status === "completed") {
|
|
3227
3434
|
lines.push(` synthesis: completed, $${(result.synthesis.cost ?? 0).toFixed(6)}`);
|
|
3228
3435
|
if (result.topFindings.length > 0) {
|
|
@@ -3232,6 +3439,9 @@ export function collectResultText(result) {
|
|
|
3232
3439
|
}
|
|
3233
3440
|
}
|
|
3234
3441
|
}
|
|
3442
|
+
else {
|
|
3443
|
+
passInFlight("synthesis", result.synthesis);
|
|
3444
|
+
}
|
|
3235
3445
|
if (result.triage.status === "completed") {
|
|
3236
3446
|
lines.push(` triage: completed, $${(result.triage.cost ?? 0).toFixed(6)}`);
|
|
3237
3447
|
if (result.topTriageItems.length > 0) {
|
|
@@ -3242,8 +3452,8 @@ export function collectResultText(result) {
|
|
|
3242
3452
|
}
|
|
3243
3453
|
}
|
|
3244
3454
|
}
|
|
3245
|
-
else
|
|
3246
|
-
|
|
3455
|
+
else {
|
|
3456
|
+
passInFlight("triage", result.triage);
|
|
3247
3457
|
}
|
|
3248
3458
|
lines.push("", "Disclaimer: Broad-Side findings are unverified scouting signals from a batch model, not validated claims.");
|
|
3249
3459
|
return lines.join("\n");
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
export interface CompletionCandidate {
|
|
2
|
+
value: string;
|
|
3
|
+
label?: string;
|
|
4
|
+
description?: string;
|
|
5
|
+
}
|
|
6
|
+
export interface CompletionItem {
|
|
7
|
+
value: string;
|
|
8
|
+
label: string;
|
|
9
|
+
description?: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* Complete the last whitespace-separated token of `prefix` from `candidates`,
|
|
13
|
+
* returning items whose `value` is the full argument text Pi should put in
|
|
14
|
+
* the line — every earlier token, then the candidate. A candidate already
|
|
15
|
+
* typed as an earlier token is not offered again. Returns null when nothing
|
|
16
|
+
* matches, which is what Pi expects for "no popup".
|
|
17
|
+
*/
|
|
18
|
+
export declare function completeLastToken(prefix: string, candidates: readonly CompletionCandidate[]): CompletionItem[] | null;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// Argument completion for the /codecarto-* slash commands.
|
|
2
|
+
//
|
|
3
|
+
// Pi hands `getArgumentCompletions` everything typed after the command name,
|
|
4
|
+
// and when the user accepts an item it replaces all of that text with the
|
|
5
|
+
// item's `value` (pi 0.85: `argumentText = textBeforeCursor.slice(spaceIndex
|
|
6
|
+
// + 1)` on the way in, `applyCompletion` slicing `cursorCol - prefix.length`
|
|
7
|
+
// on the way out). A completer that matches the *last* token but returns a
|
|
8
|
+
// bare flag therefore erases every flag typed before it: with the popup open,
|
|
9
|
+
// `/codecarto-next --auto --llm-steer` became `/codecarto-next --llm-steer` on
|
|
10
|
+
// Enter, the steered phase ran alone, and the auto run never started. The
|
|
11
|
+
// same shape turned `--auto --strict` into `--strict`, the one combination
|
|
12
|
+
// the parser rejects.
|
|
13
|
+
//
|
|
14
|
+
// So: match the last token, but return the whole line.
|
|
15
|
+
/**
|
|
16
|
+
* Complete the last whitespace-separated token of `prefix` from `candidates`,
|
|
17
|
+
* returning items whose `value` is the full argument text Pi should put in
|
|
18
|
+
* the line — every earlier token, then the candidate. A candidate already
|
|
19
|
+
* typed as an earlier token is not offered again. Returns null when nothing
|
|
20
|
+
* matches, which is what Pi expects for "no popup".
|
|
21
|
+
*/
|
|
22
|
+
export function completeLastToken(prefix, candidates) {
|
|
23
|
+
const tokens = prefix.split(/\s+/);
|
|
24
|
+
const last = tokens.pop() ?? "";
|
|
25
|
+
const earlier = tokens.filter((token) => token.length > 0);
|
|
26
|
+
const head = earlier.join(" ");
|
|
27
|
+
const typed = new Set(earlier);
|
|
28
|
+
const items = candidates
|
|
29
|
+
.filter((candidate) => candidate.value.startsWith(last) && !typed.has(candidate.value))
|
|
30
|
+
.map((candidate) => ({
|
|
31
|
+
value: head ? `${head} ${candidate.value}` : candidate.value,
|
|
32
|
+
label: candidate.label ?? candidate.value,
|
|
33
|
+
...(candidate.description && { description: candidate.description }),
|
|
34
|
+
}));
|
|
35
|
+
return items.length > 0 ? items : null;
|
|
36
|
+
}
|
|
@@ -6,6 +6,7 @@ import { parseDashboardFlags } from "./dashboard-flags.js";
|
|
|
6
6
|
import { narrateDashboard } from "./dashboard-narrator.js";
|
|
7
7
|
import { parseBroadsideFlags, KNOWN_BROADSIDE_TOKENS } from "./broadside-flags.js";
|
|
8
8
|
import { parseNextFlags } from "./next-flags.js";
|
|
9
|
+
import { completeLastToken } from "./completions.js";
|
|
9
10
|
import { buildPiGuideMessage } from "./guide-framing.js";
|
|
10
11
|
import { isCtxLive, notifyCtx } from "./notify.js";
|
|
11
12
|
import { phaseCompactionExtension } from "./phase-compaction.js";
|
|
@@ -432,12 +433,7 @@ export default function codeCartographerExtension(pi) {
|
|
|
432
433
|
});
|
|
433
434
|
pi.registerCommand("codecarto-init", {
|
|
434
435
|
description: "Initialize .codecarto/ in the current repository",
|
|
435
|
-
getArgumentCompletions: (prefix) => {
|
|
436
|
-
const items = Object.keys(PIPELINE_ALIASES)
|
|
437
|
-
.filter((value) => value.startsWith(prefix))
|
|
438
|
-
.map((value) => ({ value, label: value }));
|
|
439
|
-
return items.length > 0 ? items : null;
|
|
440
|
-
},
|
|
436
|
+
getArgumentCompletions: (prefix) => completeLastToken(prefix, Object.keys(PIPELINE_ALIASES).map((value) => ({ value }))),
|
|
441
437
|
handler: async (args, ctx) => {
|
|
442
438
|
const trimmedArgs = args.trim();
|
|
443
439
|
const pipelineChoice = resolvePipelineChoice(trimmedArgs);
|
|
@@ -536,12 +532,7 @@ export default function codeCartographerExtension(pi) {
|
|
|
536
532
|
});
|
|
537
533
|
pi.registerCommand("codecarto-switch-pipeline", {
|
|
538
534
|
description: "Switch the active pipeline without losing findings or progress: /codecarto-switch-pipeline <variant>",
|
|
539
|
-
getArgumentCompletions: (prefix) => {
|
|
540
|
-
const items = Object.keys(PIPELINE_ALIASES)
|
|
541
|
-
.filter((value) => value.startsWith(prefix))
|
|
542
|
-
.map((value) => ({ value, label: value }));
|
|
543
|
-
return items.length > 0 ? items : null;
|
|
544
|
-
},
|
|
535
|
+
getArgumentCompletions: (prefix) => completeLastToken(prefix, Object.keys(PIPELINE_ALIASES).map((value) => ({ value }))),
|
|
545
536
|
handler: async (args, ctx) => {
|
|
546
537
|
const trimmedArgs = args.trim();
|
|
547
538
|
if (!trimmedArgs) {
|
|
@@ -595,16 +586,17 @@ export default function codeCartographerExtension(pi) {
|
|
|
595
586
|
// --strict is offered only once --auto is present, because on its own
|
|
596
587
|
// it is rejected — suggesting it standalone invites the one error the
|
|
597
588
|
// parser has.
|
|
589
|
+
// Each item's value is the whole argument line (see completions.ts):
|
|
590
|
+
// accepting `--llm-steer` after `--auto ` keeps the `--auto`.
|
|
598
591
|
const autoAlreadyTyped = prefix.includes("--auto");
|
|
599
|
-
|
|
600
|
-
{ value: "--auto",
|
|
601
|
-
{ value: "--llm-steer",
|
|
602
|
-
{ value: "--no-llm-steer",
|
|
592
|
+
return completeLastToken(prefix, [
|
|
593
|
+
{ value: "--auto", description: "run every remaining phase back to back (recommended with --llm-steer)" },
|
|
594
|
+
{ value: "--llm-steer", description: "seed each phase from the previous phase's closeout; no effect on the first phase" },
|
|
595
|
+
{ value: "--no-llm-steer", description: "force steering off when the workspace config turns it on" },
|
|
603
596
|
...(autoAlreadyTyped
|
|
604
|
-
? [{ value: "--strict",
|
|
597
|
+
? [{ value: "--strict", description: "with --auto: stop on PASS WITH GAPS instead of advancing" }]
|
|
605
598
|
: []),
|
|
606
|
-
]
|
|
607
|
-
return items.length > 0 ? items : null;
|
|
599
|
+
]);
|
|
608
600
|
},
|
|
609
601
|
handler: async (args, ctx) => {
|
|
610
602
|
const flags = parseNextFlags(args);
|
|
@@ -967,10 +959,7 @@ export default function codeCartographerExtension(pi) {
|
|
|
967
959
|
description: "Read the packaged CodeCartographer agent guide into the session: /codecarto-guide [topic]",
|
|
968
960
|
getArgumentCompletions: async (prefix) => {
|
|
969
961
|
const topics = await listGuideTopics().catch(() => ["overview"]);
|
|
970
|
-
|
|
971
|
-
.filter((value) => value.startsWith(prefix))
|
|
972
|
-
.map((value) => ({ value, label: value }));
|
|
973
|
-
return items.length > 0 ? items : null;
|
|
962
|
+
return completeLastToken(prefix, topics.map((value) => ({ value })));
|
|
974
963
|
},
|
|
975
964
|
handler: async (args, ctx) => {
|
|
976
965
|
// The guide is packaged with the extension, not copied into a
|
|
@@ -1004,12 +993,9 @@ export default function codeCartographerExtension(pi) {
|
|
|
1004
993
|
});
|
|
1005
994
|
pi.registerCommand("codecarto-broadside", {
|
|
1006
995
|
description: "Batch reconnaissance (Broad-Side): /codecarto-broadside [submit|collect|status|models] [lenses…] [--model=ID] [--lens-model=LENS:ID] [flags]",
|
|
1007
|
-
|
|
1008
|
-
|
|
1009
|
-
|
|
1010
|
-
.map((value) => ({ value, label: value }));
|
|
1011
|
-
return items.length > 0 ? items : null;
|
|
1012
|
-
},
|
|
996
|
+
// Completes the token under the cursor, so lens names and flags are
|
|
997
|
+
// offered after the action too, and keeps everything typed before it.
|
|
998
|
+
getArgumentCompletions: (prefix) => completeLastToken(prefix, KNOWN_BROADSIDE_TOKENS.map((value) => ({ value }))),
|
|
1013
999
|
handler: async (args, ctx) => {
|
|
1014
1000
|
const flags = parseBroadsideFlags(args);
|
|
1015
1001
|
if (flags.unknown.length > 0) {
|
|
@@ -1439,12 +1425,7 @@ export default function codeCartographerExtension(pi) {
|
|
|
1439
1425
|
});
|
|
1440
1426
|
pi.registerCommand("codecarto-dashboard", {
|
|
1441
1427
|
description: "Regenerate .codecarto/dashboard.html (use --narrate for an LLM executive summary)",
|
|
1442
|
-
getArgumentCompletions: (prefix) => {
|
|
1443
|
-
const items = ["--narrate"]
|
|
1444
|
-
.filter((value) => value.startsWith(prefix))
|
|
1445
|
-
.map((value) => ({ value, label: value }));
|
|
1446
|
-
return items.length > 0 ? items : null;
|
|
1447
|
-
},
|
|
1428
|
+
getArgumentCompletions: (prefix) => completeLastToken(prefix, [{ value: "--narrate" }]),
|
|
1448
1429
|
handler: async (args, ctx) => {
|
|
1449
1430
|
const flags = parseDashboardFlags(args);
|
|
1450
1431
|
if (flags.unknown.length > 0) {
|
|
@@ -1515,10 +1496,7 @@ export default function codeCartographerExtension(pi) {
|
|
|
1515
1496
|
description: "Apply a post-pipeline amendment from .codecarto/scratch/amendments/, after a preview: /codecarto-amend <name | scratch/amendments/name.yaml>",
|
|
1516
1497
|
getArgumentCompletions: async (prefix) => {
|
|
1517
1498
|
const names = await listAmendmentNames(join(sessionCwd ?? process.cwd(), ".codecarto"));
|
|
1518
|
-
|
|
1519
|
-
.filter((value) => value.startsWith(prefix))
|
|
1520
|
-
.map((value) => ({ value, label: value }));
|
|
1521
|
-
return items.length > 0 ? items : null;
|
|
1499
|
+
return completeLastToken(prefix, names.map((value) => ({ value })));
|
|
1522
1500
|
},
|
|
1523
1501
|
handler: async (args, ctx) => {
|
|
1524
1502
|
const state = await ensureWorkspaceState(ctx);
|
|
@@ -1151,6 +1151,7 @@ export async function handleBroadside(args) {
|
|
|
1151
1151
|
includeSynthesis,
|
|
1152
1152
|
includeTriage,
|
|
1153
1153
|
retryTruncated,
|
|
1154
|
+
signal: serverLifetime?.signal,
|
|
1154
1155
|
// One line per *change* of a lens's status. Every poll used to
|
|
1155
1156
|
// append a line, so a four-minute wait returned twenty-six
|
|
1156
1157
|
// "in_progress (0/1)" lines before the result (0.22.0 live run).
|
|
@@ -1179,6 +1180,7 @@ export async function handleBroadside(args) {
|
|
|
1179
1180
|
includeSynthesis,
|
|
1180
1181
|
includeTriage,
|
|
1181
1182
|
retryTruncated,
|
|
1183
|
+
signal: serverLifetime?.signal,
|
|
1182
1184
|
...(runId && { runId }),
|
|
1183
1185
|
}).catch((error) => {
|
|
1184
1186
|
throw new McpError(ErrorCode.InvalidRequest, error instanceof Error ? error.message : String(error));
|
|
@@ -1518,7 +1520,7 @@ const TOOLS = [
|
|
|
1518
1520
|
},
|
|
1519
1521
|
wait_seconds: {
|
|
1520
1522
|
type: "number",
|
|
1521
|
-
description: "For submit: after submitting, poll up to this many seconds before returning. For collect: poll up to this many seconds before returning with partial state. 0 polls each in-flight batch once and returns without waiting. Falls back to wait_seconds in .codecarto/broadside/config.yaml (default 0).",
|
|
1523
|
+
description: "For submit: after submitting, poll up to this many seconds before returning. For collect: poll up to this many seconds before returning with partial state. 0 polls each in-flight batch once and returns without waiting. Falls back to wait_seconds in .codecarto/broadside/config.yaml (default 0). The wait is also bounded by the host's own tool-call timeout: if the host gives up first, the server stops polling and submits nothing further, the batches keep running server-side, and the next collect claims them — so prefer submit, then collect later, over a wait longer than the host allows.",
|
|
1522
1524
|
},
|
|
1523
1525
|
include_synthesis: {
|
|
1524
1526
|
type: "boolean",
|
|
@@ -1617,8 +1619,27 @@ export function buildServer() {
|
|
|
1617
1619
|
});
|
|
1618
1620
|
return server;
|
|
1619
1621
|
}
|
|
1622
|
+
/**
|
|
1623
|
+
* Fires when the stdio client goes away, so a Broad-Side wait that outlived
|
|
1624
|
+
* the request that asked for it stops polling and submits nothing further
|
|
1625
|
+
* (#322). Batches already accepted keep running server-side; the next collect
|
|
1626
|
+
* claims them. Set only by {@link startStdioServer}; handlers driven directly
|
|
1627
|
+
* (tests, in-process callers) see no signal.
|
|
1628
|
+
*/
|
|
1629
|
+
let serverLifetime = null;
|
|
1620
1630
|
export async function startStdioServer() {
|
|
1621
1631
|
const server = buildServer();
|
|
1622
1632
|
const transport = new StdioServerTransport();
|
|
1633
|
+
serverLifetime = new AbortController();
|
|
1634
|
+
const lifetime = serverLifetime;
|
|
1635
|
+
server.onclose = () => lifetime.abort();
|
|
1636
|
+
// The SDK's stdio transport listens for stdin `data` and `error` only — it
|
|
1637
|
+
// never sees the end of the stream — so a client that exits mid-request
|
|
1638
|
+
// leaves the server polling with nobody to answer to (#322, observed: a
|
|
1639
|
+
// server outlived its client by fifteen minutes and submitted two paid
|
|
1640
|
+
// post-passes on its own). End of stdin is the client going away.
|
|
1641
|
+
const gone = () => lifetime.abort();
|
|
1642
|
+
process.stdin.once("end", gone);
|
|
1643
|
+
process.stdin.once("close", gone);
|
|
1623
1644
|
await server.connect(transport);
|
|
1624
1645
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "codecartographer-pi",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.3",
|
|
4
4
|
"mcpName": "io.github.HuginnIndustries/codecartographer",
|
|
5
5
|
"description": "Turn an unfamiliar codebase into a validated reimplementation spec, then synthesize confirmed specs and a product vision into a traceable plan.",
|
|
6
6
|
"type": "module",
|