@trim21/personal-pi-extensions 0.1.546 → 0.1.548

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@trim21/personal-pi-extensions",
3
- "version": "0.1.546",
3
+ "version": "0.1.548",
4
4
  "type": "module",
5
5
  "description": "Custom pi coding-agent extensions: bwrap sandbox, workspace guard, opencode edit, and more",
6
6
  "keywords": [
@@ -440,7 +440,7 @@ export class BwrapRuntime {
440
440
  // 兼容 pi local ops 抛的 new Error("aborted")
441
441
  if (error instanceof Error && (error.name === "AbortError" || error.message === "aborted")) {
442
442
  const partial = await this.finalizeOutput(output);
443
- throw new BashInterruptedError("aborted", "Command aborted", partial, error);
443
+ throw new BashInterruptedError("aborted", "Command aborted by user", partial, error);
444
444
  }
445
445
  throw error;
446
446
  } finally {
@@ -148,7 +148,7 @@ export function registerShellTools(
148
148
  );
149
149
  if (error.kind === "aborted") {
150
150
  // 用户取消:直接返回已捕获的输出,不抛错
151
- const full = text ? `${text}\n\nCommand aborted` : "Command aborted";
151
+ const full = text ? `${text}\n\nCommand aborted by user` : "Command aborted by user";
152
152
  return { content: [{ type: "text", text: full }], details: undefined };
153
153
  }
154
154
  const full = text
@@ -262,6 +262,7 @@ const stepSchema = Type.Object({
262
262
  number: Type.Number(),
263
263
  status: Type.String(),
264
264
  conclusion: Type.Union([Type.String(), Type.Null()]),
265
+ started_at: Type.Optional(Type.Union([Type.String(), Type.Null()])),
265
266
  });
266
267
 
267
268
  const jobRunSchema = Type.Object({
@@ -401,6 +402,8 @@ export interface StepInfo {
401
402
  number: number;
402
403
  status: string;
403
404
  conclusion: string | null;
405
+ /** ISO-8601 UTC, second precision. Null for steps that never started. */
406
+ started_at?: string | null;
404
407
  }
405
408
 
406
409
  // 模块级串行状态:同一资源(如 CI 日志)的请求排队执行,配合函数内部的
@@ -529,115 +532,194 @@ export interface StepSpan {
529
532
  end: number;
530
533
  }
531
534
 
535
+ /** The part of a step the log index needs. `StepInfo` satisfies it. */
536
+ export interface StepRef {
537
+ number: number;
538
+ name: string;
539
+ conclusion?: string | null;
540
+ started_at?: string | null;
541
+ }
542
+
543
+ /** A `##[group]Run …` / `##[group]Post Run …` line: the header of one executed step. */
544
+ interface StepHeader {
545
+ /** 0-based index of the `##[group]` line. */
546
+ line: number;
547
+ /** Header text with the `Run ` / `Post Run ` prefix stripped. */
548
+ action: string;
549
+ /** Runner timestamp on that line (epoch ms), null when unparsable. */
550
+ timestamp: number | null;
551
+ }
552
+
553
+ /** Runner timestamp every log line starts with: `2026-08-05T16:36:08.1842645Z `. */
554
+ const LOG_TIMESTAMP_RE = /^\uFEFF?(\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?Z) /;
555
+ const HEADER_PREFIX_RE = /^(Run |Post Run )/;
556
+
532
557
  /**
533
- * Locate a job's steps in its raw log by matching step names to "Run " groups.
534
- *
535
- * Each top-level step emits a `##[group]Run <name>` / `##[group]Post Run <name>`
536
- * marker at depth 1. Composite actions emit their internal steps as *additional*
537
- * depth-1 groups *after* the composite's own `##[endgroup]` (e.g. the internal
538
- * `Run actions/setup-python@…` groups inside `Run pypa/cibuildwheel@…`), so the
539
- * log's "Run " groups are NOT one-per-step.
540
- *
541
- * To handle that we treat a group as an *anchor* only when its action name
542
- * (after stripping the "Run "/"Post Run " prefix) matches a top-level API step
543
- * name. Composite-action internals match no API step and are absorbed into the
544
- * span of the enclosing step instead of truncating it.
545
- *
546
- * Step 1 ("Set up job") maps to everything before the first anchor group.
547
- * Steps with an anchor map to the span from their anchor to the next anchor.
548
- * Explicitly named steps that lack a "Run " prefix (e.g. a step named
549
- * "Setup node" running actions/setup-node) are located between the previous
550
- * and next anchor's groups.
551
- * Steps that were skipped and never executed get no span.
558
+ * The runner writes a step's header within ~1.6s of the step's API `started_at`
559
+ * (the API truncates its timestamps to whole seconds), so a header inside this
560
+ * window is evidence of the step it belongs to.
552
561
  */
553
- export function stepLineSpans(
554
- log: string,
555
- apiSteps: { number: number; name: string }[],
556
- ): Map<number, StepSpan> {
557
- const lines = log.split("\n");
558
- const spans = new Map<number, StepSpan>();
562
+ const HEADER_WINDOW_MS = 5_000;
563
+ /** An exact name match proves the header belongs to that step. */
564
+ const NAME_MATCH_SCORE = 4;
565
+ /** Weight of a header inside the step's start window. */
566
+ const TIME_MATCH_SCORE = 2;
567
+
568
+ function headerTimestamp(line: string): number | null {
569
+ const match = LOG_TIMESTAMP_RE.exec(line);
570
+ if (match === null) return null;
571
+ const ms = Date.parse(match[1]);
572
+ return Number.isNaN(ms) ? null : ms;
573
+ }
559
574
 
560
- // Collect depth-1 "Run "/"Post Run " groups in log order.
561
- const groups: { line: number; action: string }[] = [];
575
+ /** Collect the depth-1 `Run ` / `Post Run ` headers, in log order. */
576
+ function stepHeaders(lines: string[]): StepHeader[] {
577
+ const headers: StepHeader[] = [];
562
578
  let depth = 0;
563
579
  for (const [i, line] of lines.entries()) {
564
580
  if (line.includes("##[endgroup]")) {
565
581
  if (depth > 0) depth--;
566
582
  continue;
567
583
  }
568
- if (line.includes("##[group]")) {
569
- depth++;
570
- if (depth === 1) {
571
- const m = /##\[group\](.*)/.exec(line);
572
- const name = m ? m[1].trim() : "";
573
- if (name.startsWith("Run ") || name.startsWith("Post Run ")) {
574
- groups.push({ line: i, action: name.replace(/^(Run |Post Run )/, "").trim() });
575
- }
576
- }
584
+ if (!line.includes("##[group]")) continue;
585
+ depth++;
586
+ if (depth !== 1) continue;
587
+ const name = /##\[group\](.*)/.exec(line)?.[1].trim() ?? "";
588
+ if (HEADER_PREFIX_RE.test(name)) {
589
+ headers.push({
590
+ line: i,
591
+ action: name.replace(HEADER_PREFIX_RE, "").trim(),
592
+ timestamp: headerTimestamp(line),
593
+ });
577
594
  }
578
595
  }
596
+ return headers;
597
+ }
579
598
 
580
- // API steps that produce a "Run "/"Post Run " log group, in step order.
581
- const runSteps = apiSteps
582
- .filter((s) => /^(Run |Post Run )/.test(s.name))
583
- .map((s) => ({ number: s.number, action: s.name.replace(/^(Run |Post Run )/, "").trim() }))
584
- .toSorted((a, b) => a.number - b.number);
585
-
586
- // Greedily assign each run step the first unclaimed group whose action name
587
- // matches (log order). Leftover groups are composite-action internals.
588
- const used = new Set<number>();
589
- const stepToGroup = new Map<number, number>(); // api step number -> group index
590
- for (const rs of runSteps) {
591
- const gi = groups.findIndex((g, idx) => !used.has(idx) && g.action === rs.action);
592
- if (gi !== -1) {
593
- used.add(gi);
594
- stepToGroup.set(rs.number, gi);
599
+ /** How well a step explains a header — 0 means "no evidence, don't guess". */
600
+ function headerScore(step: StepRef, header: StepHeader): number {
601
+ let score = 0;
602
+ const named = step.name.replace(HEADER_PREFIX_RE, "").trim();
603
+ if (HEADER_PREFIX_RE.test(step.name) && named === header.action) {
604
+ score += NAME_MATCH_SCORE;
605
+ }
606
+ const started = step.started_at == null ? NaN : Date.parse(step.started_at);
607
+ if (!Number.isNaN(started) && header.timestamp !== null) {
608
+ const delta = header.timestamp - started;
609
+ if (delta >= 0 && delta <= HEADER_WINDOW_MS) {
610
+ score += TIME_MATCH_SCORE * (1 - delta / HEADER_WINDOW_MS);
595
611
  }
596
612
  }
613
+ return score;
614
+ }
597
615
 
598
- // Anchor sequence in log order.
599
- const anchors = [...stepToGroup]
600
- .map(([stepNum, gi]) => ({ stepNum, line: groups[gi].line }))
601
- .toSorted((a, b) => a.line - b.line);
602
-
603
- for (const s of apiSteps) {
604
- let start: number;
605
- let end: number;
606
-
607
- if (s.number === 1) {
608
- // Step 1 ("Set up job"): everything before the first "Run " group.
609
- start = 0;
610
- end = groups[0]?.line ?? lines.length;
611
- } else {
612
- const anchorIdx = anchors.findIndex((a) => a.stepNum === s.number);
613
- if (anchorIdx === -1) {
614
- // Non-anchor step (explicitly named, e.g. "Setup node"): its group sits
615
- // in the gap between the previous and next anchors' groups. Take the
616
- // first unclaimed group in that span.
617
- const prevAnchor = anchors.reduce<{ stepNum: number; line: number } | undefined>(
618
- (acc, a) => (a.stepNum < s.number ? a : acc),
619
- undefined,
620
- );
621
- const nextAnchor = anchors.find((a) => a.stepNum > s.number);
622
- const gapStart = prevAnchor ? prevAnchor.line + 1 : 0;
623
- const gapEnd = nextAnchor ? nextAnchor.line : lines.length;
616
+ /**
617
+ * Align steps with headers: an order-preserving best-scoring matching, where
618
+ * either side may be left unmatched. Only pairs with real evidence are matched,
619
+ * so a step whose block cannot be identified gets no span instead of a guess.
620
+ *
621
+ * Name evidence disappears as soon as the workflow names a step with `name:`
622
+ * (the API name is then the custom one, while the log header carries the action
623
+ * or command), which is why the timestamps matter too. Headers belonging to a
624
+ * composite action's *internal* steps carry no evidence for any API step, so
625
+ * they stay unmatched and are absorbed into the enclosing step's span.
626
+ */
627
+ function alignStepsToHeaders(steps: StepRef[], headers: StepHeader[]): Map<number, number> {
628
+ const n = steps.length;
629
+ const m = headers.length;
630
+ // Equal-scoring alignments are decided in favour of the earlier step: a step
631
+ // whose output the runner never logged (post steps, "Complete job") comes last
632
+ // in step order, so a header claimed by both belongs to the earlier one. A
633
+ // step that ran always emits its header before the next step starts, which
634
+ // leaves several steps competing for one header whenever the API timestamps
635
+ // (whole seconds) collapse them into the same second.
636
+ const TIE_BREAK = 1e-6;
637
+ const scores = steps.map((step, i) =>
638
+ headers.map((header) => {
639
+ const score = headerScore(step, header);
640
+ return score > 0 ? score + TIE_BREAK * (n - i) : 0;
641
+ }),
642
+ );
643
+ // best[i][j]: score of aligning the first i steps with the first j headers.
644
+ const best: number[][] = Array.from({ length: n + 1 }, () =>
645
+ Array.from({ length: m + 1 }, () => 0),
646
+ );
647
+ const paired: boolean[][] = Array.from({ length: n + 1 }, () =>
648
+ Array.from({ length: m + 1 }, () => false),
649
+ );
624
650
 
625
- const gi = groups.findIndex(
626
- (g, idx) => !used.has(idx) && g.line >= gapStart && g.line < gapEnd,
627
- );
628
- if (gi === -1) continue; // step never ran → no block in the log
629
- start = groups[gi].line;
630
- end = gapEnd;
651
+ for (let i = 1; i <= n; i++) {
652
+ for (let j = 1; j <= m; j++) {
653
+ const pairing = scores[i - 1][j - 1];
654
+ const withPairing = pairing > 0 ? best[i - 1][j - 1] + pairing : -Infinity;
655
+ if (withPairing >= best[i - 1][j] && withPairing >= best[i][j - 1]) {
656
+ best[i][j] = withPairing;
657
+ paired[i][j] = true;
631
658
  } else {
632
- // Direct anchor hit: span from this anchor to the next one.
633
- start = anchors[anchorIdx].line;
634
- end = anchorIdx + 1 < anchors.length ? anchors[anchorIdx + 1].line : lines.length;
659
+ best[i][j] = Math.max(best[i - 1][j], best[i][j - 1]);
635
660
  }
636
661
  }
662
+ }
663
+
664
+ const assignment = new Map<number, number>(); // step number -> header index
665
+ for (let i = n, j = m; i > 0 && j > 0;) {
666
+ if (paired[i][j]) {
667
+ assignment.set(steps[i - 1].number, j - 1);
668
+ i--;
669
+ j--;
670
+ } else if (best[i - 1][j] >= best[i][j - 1]) {
671
+ i--;
672
+ } else {
673
+ j--;
674
+ }
675
+ }
676
+ return assignment;
677
+ }
678
+
679
+ /** Exclusive end index with trailing blank lines dropped, so a span slices to real text. */
680
+ function trimTrailingBlankLines(lines: string[], start: number, end: number): number {
681
+ while (end > start && (lines[end - 1] ?? "").trim() === "") end--;
682
+ return end;
683
+ }
684
+
685
+ /**
686
+ * Locate a job's steps in its raw log: each executed step emits a depth-1
687
+ * `##[group]Run <x>` / `##[group]Post Run <x>` header, and its block runs from
688
+ * that header up to the next executed step's header. Everything in between —
689
+ * the action's own `::group::` output, a composite action's internal step
690
+ * headers — belongs to the enclosing step.
691
+ *
692
+ * "Set up job" emits no header: it owns the runner preamble before the first
693
+ * header. Steps with no header of their own (skipped steps, post steps the
694
+ * runner never logged, "Complete job") get no span.
695
+ */
696
+ export function stepLineSpans(log: string, apiSteps: StepRef[]): Map<number, StepSpan> {
697
+ const lines = log.split("\n");
698
+ const headers = stepHeaders(lines);
699
+ const spans = new Map<number, StepSpan>();
700
+
701
+ // "Set up job" is the runner's own preamble and never takes part in matching:
702
+ // its start window overlaps the first real step's header.
703
+ const preamble = apiSteps.find((s) => s.number === 1 && s.name === "Set up job");
704
+ if (preamble !== undefined) {
705
+ const end = trimTrailingBlankLines(lines, 0, headers[0]?.line ?? lines.length);
706
+ spans.set(preamble.number, { start: 0, end });
707
+ }
708
+
709
+ // A skipped step never started, so it emitted no header — and its name often
710
+ // repeats another step's ("Clear build" twice, "Post Run <action>" next to
711
+ // its "Run <action>"), which would let it steal that step's block.
712
+ const assignment = alignStepsToHeaders(
713
+ apiSteps.filter((s) => s !== preamble && s.conclusion !== "skipped"),
714
+ headers,
715
+ );
716
+ const placed = [...assignment]
717
+ .map(([number, headerIndex]) => ({ number, line: headers[headerIndex].line }))
718
+ .toSorted((a, b) => a.line - b.line);
637
719
 
638
- // Drop trailing blank lines so the span slices to the step's own text.
639
- while (end > start && lines[end - 1].trim() === "") end--;
640
- spans.set(s.number, { start, end });
720
+ for (const [index, step] of placed.entries()) {
721
+ const end = trimTrailingBlankLines(lines, step.line, placed[index + 1]?.line ?? lines.length);
722
+ spans.set(step.number, { start: step.line, end });
641
723
  }
642
724
 
643
725
  return spans;
@@ -109,7 +109,7 @@ export default function opencodeBash(
109
109
  const status =
110
110
  error.kind === "timeout"
111
111
  ? `Command exceeded timeout of ${timeout} ms. Retry with a larger timeout if the command is expected to take longer.`
112
- : "Command aborted";
112
+ : "Command aborted by user";
113
113
  const full = text ? `${text}\n\n${status}` : status;
114
114
  // 对齐上游 opencode:超时与中断都不抛错,输出与状态文本一起返回
115
115
  return {