@dorokuma/herdsman-pi 0.13.3 → 0.13.5

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": "@dorokuma/herdsman-pi",
3
- "version": "0.13.3",
3
+ "version": "0.13.5",
4
4
  "description": "Pi extension bridge for Herdsman agent history.",
5
5
  "type": "module",
6
6
  "keywords": [
@@ -4,7 +4,7 @@ export const TURN_SIGNAL_TIMEOUT_MS = 3_000;
4
4
  export const TURN_SIGNAL_POLL_MS = 50;
5
5
  export const TURN_SIGNAL_TAIL_CHARS = 8_000;
6
6
 
7
- export type TurnCompletionCheckReason = "already_written" | "new_content" | "timeout" | "unavailable";
7
+ export type TurnCompletionCheckReason = "already_written" | "timeout" | "unavailable";
8
8
 
9
9
  export type TurnCompletionCheck = {
10
10
  confirmed: boolean;
@@ -56,11 +56,30 @@ function defaultProbe(): SessionWriteProbe {
56
56
  /**
57
57
  * Bounded confirmation that the final assistant message reached the Pi session
58
58
  * file before the extension signals the daemon. The message may already be on
59
- * disk when the `message_end` hook fires (checked via the file tail containing
60
- * the expected text or its distinctive ending); otherwise the file size is
61
- * polled until new content appears. A timeout (or an unavailable file) still
62
- * resolves so the caller can signal with the actual status instead of hanging
63
- * forever.
59
+ * disk when the `message_end` hook fires; otherwise the file tail is polled
60
+ * until the expected text (or its JSONL-escaped form) appears.
61
+ *
62
+ * The root cause of the mismatches this guards against is the source of
63
+ * `expectedText`, not the tail it is compared to: the extension reports the
64
+ * *sanitized* text it saw at `message_end` (`assistantMessageText` in
65
+ * `index.ts` runs the body through `sanitizeText`), while rewrite-type
66
+ * extensions (e.g. `no-tables` turning `### X` into bold or tables into
67
+ * bullets, plus link handling) keep rewriting the transcript that lands on disk
68
+ * *after* that hook. The client-side text and the on-disk original are therefore
69
+ * systematically inconsistent, and the disk original is the authoritative body —
70
+ * never the reported guess. "The file grew" was only a secondary mechanism that
71
+ * let such a mismatch be *confirmed* instead of caught (tool results, streamed
72
+ * partials and any other session write also grow the file): that is how
73
+ * mismatch signals ended up as empty `agent.done` releases (sample figures and
74
+ * the caveats around them live in
75
+ * `.agents/notes/20261002-empty-wake-expectedtext-rootcause.md`). The
76
+ * daemon's `expected_text_mismatch` arm now trusts the on-disk body, so a
77
+ * timeout here degrades honestly instead of blanking it. Do not trace this back
78
+ * to tail guessing alone.
79
+ *
80
+ * A timeout (or an unavailable file) still resolves so the caller can signal
81
+ * with the actual status instead of hanging forever, and it now reports
82
+ * `confirmed: false` honestly instead of fabricating a confirmation.
64
83
  */
65
84
  export async function confirmSessionWrite(input: {
66
85
  expectedText: string;
@@ -75,8 +94,14 @@ export async function confirmSessionWrite(input: {
75
94
  const expectedText = input.expectedText.trim();
76
95
  const deadline = Date.now() + timeoutMs;
77
96
  const candidate = expectedText.length > 200 ? expectedText.slice(-200) : expectedText;
97
+ // The session file is JSONL, so the written text is escaped (`\n`, `\"`,
98
+ // `\\`). Matching the raw candidate only would miss every multi-line final
99
+ // message, so the escaped form of the same suffix is accepted as well.
100
+ const escapedCandidate = JSON.stringify(candidate).slice(1, -1);
78
101
  const containsText = (tail: string | null) =>
79
- tail !== null && candidate.length > 0 && tail.includes(candidate);
102
+ tail !== null &&
103
+ candidate.length > 0 &&
104
+ (tail.includes(candidate) || tail.includes(escapedCandidate));
80
105
 
81
106
  const initialSize = probe.size(input.path);
82
107
  if (initialSize === null) return { confirmed: false, reason: "unavailable" };
@@ -85,8 +110,6 @@ export async function confirmSessionWrite(input: {
85
110
  }
86
111
  while (Date.now() < deadline) {
87
112
  await probe.sleep(pollMs);
88
- const size = probe.size(input.path);
89
- if (size !== null && size > initialSize) return { confirmed: true, reason: "new_content" };
90
113
  if (containsText(probe.readTail(input.path, TURN_SIGNAL_TAIL_CHARS))) {
91
114
  return { confirmed: true, reason: "already_written" };
92
115
  }
package/src/wake.ts CHANGED
@@ -117,7 +117,20 @@ export function formatAgentOutcomeUpdates(outcomes: AgentOutcome[]): string {
117
117
  const pane = outcome.paneId ?? "unknown";
118
118
  if (outcome.kind === "failed") {
119
119
  const reason = outcome.reason && outcome.reason.length > 0 ? outcome.reason : "(unknown)";
120
- return `- failed ${identity} ${pane}\n reason: ${reason}`;
120
+ // A failed round can still have produced assistant text before it failed; a
121
+ // reason-only line left that body invisible to the orchestrator. But the
122
+ // body of a `failed` / `discarded` outcome is not this round's output:
123
+ // `#appendPlanFailedEvent` / `#appendPlanDiscardedEvent` pass the plan
124
+ // baseline snapshot (`plan.compactHistory`, written once at plan creation),
125
+ // which may hold an earlier round's answer (see the plan-baseline notes).
126
+ // Rendering it as `last assistant:` read as "this round produced this", so
127
+ // the label says pre-round. The wire record carries no assistant `ref`, so
128
+ // same-source cannot be decided here without widening the daemon payload;
129
+ // the label is therefore always explicit (and only dropped when the body is
130
+ // empty, keeping the reason-only line byte-for-byte unchanged).
131
+ const body =
132
+ outcome.text.length > 0 ? `\n last assistant (pre-round): ${outcome.text}` : "";
133
+ return `- failed ${identity} ${pane}\n reason: ${reason}${body}`;
121
134
  }
122
135
  const excerpt = outcome.text.length > 0 ? outcome.text : "(no assistant message)";
123
136
  return `- ${outcome.kind} ${identity} ${pane}\n last assistant: ${excerpt}\n event: ${outcome.eventId}`;