switchroom 0.19.18 → 0.19.22
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-scheduler/index.js +2 -1
- package/dist/auth-broker/index.js +56 -1
- package/dist/cli/drive-write-pretool.mjs +48 -5
- package/dist/cli/ms-365-write-pretool.mjs +40 -2
- package/dist/cli/notion-write-pretool.mjs +2 -1
- package/dist/cli/switchroom.js +5242 -2239
- package/dist/host-control/main.js +12241 -11375
- package/dist/vault/approvals/kernel-server.js +113 -7
- package/dist/vault/broker/server.js +259 -76
- package/package.json +6 -3
- package/profiles/_base/start.sh.hbs +61 -1
- package/skills/switchroom-release/SKILL.md +103 -20
- package/telegram-plugin/bridge/bridge.ts +14 -0
- package/telegram-plugin/card-format.ts +92 -3
- package/telegram-plugin/dist/bridge/bridge.js +13 -0
- package/telegram-plugin/dist/gateway/gateway.js +2356 -1159
- package/telegram-plugin/dist/server.js +13 -0
- package/telegram-plugin/edit-flood-fuse.ts +477 -0
- package/telegram-plugin/format.ts +19 -7
- package/telegram-plugin/gateway/always-allow-persist-queue.ts +97 -11
- package/telegram-plugin/gateway/boot-sweep-gate.ts +164 -0
- package/telegram-plugin/gateway/callback-query-handlers.ts +454 -81
- package/telegram-plugin/gateway/gateway.ts +66 -56
- package/telegram-plugin/gateway/inbound-interceptors.ts +27 -4
- package/telegram-plugin/gateway/missed-approvals-store.ts +66 -17
- package/telegram-plugin/gateway/narrative-lane.ts +49 -3
- package/telegram-plugin/gateway/pending-card-store.ts +46 -16
- package/telegram-plugin/gateway/scoped-grant-store.ts +39 -14
- package/telegram-plugin/gateway/status-pin-api.ts +145 -0
- package/telegram-plugin/gateway/store-file.ts +244 -0
- package/telegram-plugin/hooks/subagent-tracker-posttool.mjs +325 -45
- package/telegram-plugin/hooks/tool-label-pretool.mjs +88 -2
- package/telegram-plugin/retry-api-call.ts +15 -2
- package/telegram-plugin/send-gate.ts +1 -1
- package/telegram-plugin/status-no-truncate.ts +64 -1
- package/telegram-plugin/status-pin-driver.ts +50 -27
- package/telegram-plugin/status-pin.ts +43 -5
- package/telegram-plugin/tests/activity-card-send-gate.test.ts +275 -0
- package/telegram-plugin/tests/activity-card-wiring.test.ts +16 -7
- package/telegram-plugin/tests/boot-pin-sweep-wiring.test.ts +101 -0
- package/telegram-plugin/tests/boot-sweep-gate.test.ts +293 -0
- package/telegram-plugin/tests/boot-version-string.test.ts +0 -0
- package/telegram-plugin/tests/bridge-tool-parity.test.ts +95 -0
- package/telegram-plugin/tests/edit-flood-fuse.test.ts +431 -0
- package/telegram-plugin/tests/pinned-card-collapse.test.ts +356 -0
- package/telegram-plugin/tests/status-pin-api.test.ts +178 -0
- package/telegram-plugin/tests/status-pin-boot-recovery.test.ts +94 -11
- package/telegram-plugin/tests/status-pin.test.ts +106 -5
- package/telegram-plugin/tests/store-atomic-write.test.ts +411 -0
- package/telegram-plugin/tests/subagent-tracker-hooks.test.ts +631 -1
- package/telegram-plugin/tests/tool-activity-summary.test.ts +28 -12
- package/telegram-plugin/tests/tool-label-pretool.test.ts +94 -0
- package/telegram-plugin/tests/vault-approval-posture.test.ts +6 -1
- package/telegram-plugin/tests/vault-passphrase-retry.test.ts +666 -0
- package/telegram-plugin/tests/vault-request-access-unlock-resume.test.ts +42 -21
- package/telegram-plugin/tests/worker-feed-coalesce.test.ts +233 -1
- package/telegram-plugin/tests/worker-feed-repeat-steps.test.ts +147 -0
- package/telegram-plugin/tool-activity-summary.ts +85 -13
- package/telegram-plugin/worker-activity-feed.ts +56 -2
- package/vendor/hindsight-memory/scripts/drain_pending.py +847 -67
- package/vendor/hindsight-memory/scripts/lib/client.py +124 -0
- package/vendor/hindsight-memory/scripts/lib/pending.py +944 -33
- package/vendor/hindsight-memory/scripts/lib/retain_split.py +460 -0
- package/vendor/hindsight-memory/scripts/recall.py +74 -5
- package/vendor/hindsight-memory/scripts/session_start.py +48 -0
- package/vendor/hindsight-memory/scripts/tests/test_client_document_exists.py +470 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_drops.py +2275 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_failure_class.py +105 -0
- package/vendor/hindsight-memory/scripts/tests/test_pending_wedge.py +300 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_degraded_notice.py +365 -0
- package/vendor/hindsight-memory/scripts/tests/test_recall_envelope_strip_telemetry.py +12 -4
- package/vendor/hindsight-memory/scripts/tests/test_recall_transcript_fallback.py +27 -2
- package/vendor/hindsight-memory/scripts/tests/test_retain_split.py +438 -0
- package/vendor/hindsight-memory/scripts/tests/test_session_start_version_skew.py +204 -0
- package/vendor/hindsight-memory/tests/test_drain_pending.py +130 -8
- package/vendor/hindsight-memory/tests/test_pending.py +32 -7
|
@@ -89,6 +89,7 @@ import {
|
|
|
89
89
|
WORKER_HISTORY_MAX,
|
|
90
90
|
STATUS_LINE_MAX,
|
|
91
91
|
NESTED_PREFIX,
|
|
92
|
+
WORKER_STEP_INDENT,
|
|
92
93
|
} from './status-no-truncate.js'
|
|
93
94
|
import { escapeMarkdown, stripMarkdown, truncate, stackCardLines } from './card-format.js'
|
|
94
95
|
import { isTelegramSurfaceTool } from './tool-names.js'
|
|
@@ -297,6 +298,11 @@ function escapeStepLine(raw: string): string {
|
|
|
297
298
|
* `allDone` — when true ALL steps render done (✓ struck italic); when false the
|
|
298
299
|
* newest renders in-progress (→ bold)
|
|
299
300
|
* `liveSuffix` — appended INSIDE the newest in-progress line (heartbeat tick)
|
|
301
|
+
* `indent` — literal prefix put on EVERY emitted line (incl. the
|
|
302
|
+
* `+N earlier…` header), OUTSIDE the markdown spans so it never
|
|
303
|
+
* lands inside an emphasis run. Default `''` — byte-identical
|
|
304
|
+
* output for callers that don't indent. The combined worker card
|
|
305
|
+
* passes `WORKER_STEP_INDENT` to nest steps under their worker.
|
|
300
306
|
*/
|
|
301
307
|
export function renderStepFeed(
|
|
302
308
|
out: string[],
|
|
@@ -304,14 +310,19 @@ export function renderStepFeed(
|
|
|
304
310
|
allDone: boolean,
|
|
305
311
|
liveSuffix = '',
|
|
306
312
|
window: number = STATUS_ROLLING_LINES,
|
|
313
|
+
indent = '',
|
|
307
314
|
): void {
|
|
308
315
|
if (steps.length === 0) return
|
|
309
316
|
const shown = steps.slice(-Math.max(1, window))
|
|
310
317
|
const hidden = steps.length - shown.length
|
|
311
|
-
if (hidden > 0) out.push(
|
|
318
|
+
if (hidden > 0) out.push(`${indent}_✓ +${hidden} earlier…_`)
|
|
312
319
|
const lastIdx = shown.length - 1
|
|
313
320
|
shown.forEach((s, i) => {
|
|
314
|
-
out.push(
|
|
321
|
+
out.push(
|
|
322
|
+
!allDone && i === lastIdx
|
|
323
|
+
? `${indent}**→ ${s}${liveSuffix}**`
|
|
324
|
+
: `${indent}~~_✓ ${s}_~~`,
|
|
325
|
+
)
|
|
315
326
|
})
|
|
316
327
|
}
|
|
317
328
|
|
|
@@ -439,7 +450,10 @@ export function renderStatusCard(opts: StatusCardOpts): string | null {
|
|
|
439
450
|
// Stack lines with GFM hard breaks (` \n`) so the card's styled prose lines
|
|
440
451
|
// don't collapse onto one visual line in the rich-message renderer — see
|
|
441
452
|
// stackCardLines. This is what makes a card render identically to a reply.
|
|
442
|
-
|
|
453
|
+
// collapseSafe (#3666): the agent status card and the single-worker card are
|
|
454
|
+
// both PINNED, and Telegram's pinned bar shows them collapsed to one line
|
|
455
|
+
// with the newlines dropped — the separator keeps that preview legible.
|
|
456
|
+
const joined = stackCardLines(out, { collapseSafe: true })
|
|
443
457
|
if (joined.length <= STATUS_CARD_CHAR_BUDGET) return joined
|
|
444
458
|
return fitCardToBudget(opts, headerLines)
|
|
445
459
|
}
|
|
@@ -492,7 +506,8 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
492
506
|
const lastIdx = shown.length - 1
|
|
493
507
|
shown.forEach((esc, i) => lines.push(buildBullet(esc, i === lastIdx)))
|
|
494
508
|
lines.push(...footerLines)
|
|
495
|
-
|
|
509
|
+
// collapseSafe: same pinned surface as renderStatusCard (#3666).
|
|
510
|
+
const candidate = stackCardLines(lines, { collapseSafe: true })
|
|
496
511
|
if (candidate.length <= STATUS_CARD_CHAR_BUDGET) return candidate
|
|
497
512
|
}
|
|
498
513
|
|
|
@@ -518,7 +533,7 @@ function fitCardToBudget(opts: StatusCardOpts, headerLines: string[]): string {
|
|
|
518
533
|
if (parentMarker != null) lines.push(parentMarker)
|
|
519
534
|
lines.push(newestLine)
|
|
520
535
|
lines.push(...footerLines)
|
|
521
|
-
return stackCardLines(lines)
|
|
536
|
+
return stackCardLines(lines, { collapseSafe: true })
|
|
522
537
|
}
|
|
523
538
|
|
|
524
539
|
/**
|
|
@@ -729,18 +744,54 @@ export function combinedHistoryDepth(w: number): number {
|
|
|
729
744
|
/** Alias for readability at the single-worker call site — same curve. */
|
|
730
745
|
export const workerHistoryDepth = combinedHistoryDepth
|
|
731
746
|
|
|
747
|
+
/**
|
|
748
|
+
* The combined card's first line: a SELF-CONTAINED glance at the whole swarm
|
|
749
|
+
* (#3666) — `🛠 Workers · 3 running · oldest 38m16s · 196 tools · 1.2M tok`.
|
|
750
|
+
*
|
|
751
|
+
* Two jobs, both load-bearing:
|
|
752
|
+
*
|
|
753
|
+
* 1. In the feed it answers "what is my fleet doing" without reading the
|
|
754
|
+
* rows — the aggregate the per-row lines never showed.
|
|
755
|
+
* 2. In Telegram's pinned bar, where the card collapses to ONE line, it is
|
|
756
|
+
* the part most likely to survive truncation, so the preview leads with
|
|
757
|
+
* real information instead of a four-word chrome stub that ran into
|
|
758
|
+
* row 1's ordinal (`… 3 running1. Fix issue …`).
|
|
759
|
+
*
|
|
760
|
+
* It always ends in a UNIT WORD (`running` / `oldest <elapsed>` / `tools` /
|
|
761
|
+
* `tok`), never a bare number, so the first collapse seam reads as
|
|
762
|
+
* `… · 196 tools 1. Fix issue …` — a list starting after a unit — rather than
|
|
763
|
+
* two numbers colliding. Aggregates cover ALL rows (including any spilled to
|
|
764
|
+
* `+M more working…`), matching the `N running` count beside them.
|
|
765
|
+
*/
|
|
766
|
+
function glanceLine(rows: CombinedWorkerRow[]): string {
|
|
767
|
+
const oldestMs = rows.reduce((m, r) => Math.max(m, r.elapsedMs), 0)
|
|
768
|
+
const tools = rows.reduce((n, r) => n + r.toolCount, 0)
|
|
769
|
+
const tok = rows.reduce((n, r) => n + (r.totalTokens ?? 0), 0)
|
|
770
|
+
const toolWord = tools === 1 ? 'tool' : 'tools'
|
|
771
|
+
return (
|
|
772
|
+
`🛠 **Workers** · _${rows.length} running · oldest ${formatFeedElapsed(oldestMs)}` +
|
|
773
|
+
` · ${tools} ${toolWord}${tokenSegment(tok)}_`
|
|
774
|
+
)
|
|
775
|
+
}
|
|
776
|
+
|
|
732
777
|
/**
|
|
733
778
|
* Render N≥1 live workers into ONE combined feed body (ready Telegram
|
|
734
779
|
* markdown; callers send verbatim — do NOT re-escape). Layout:
|
|
735
780
|
*
|
|
736
|
-
* 🛠 **Workers** · _N
|
|
781
|
+
* 🛠 **Workers** · _N running · oldest {elapsed} · {n} tools · {t} tok_
|
|
737
782
|
* **1. {desc1}** _· {elapsed} · {n} tools_
|
|
738
|
-
*
|
|
739
|
-
*
|
|
783
|
+
* ~~_✓ {earlier step}_~~
|
|
784
|
+
* **→ {newest step}**
|
|
740
785
|
* **2. {desc2}** _· {elapsed} · {n} tools_
|
|
741
|
-
*
|
|
786
|
+
* **→ {newest step}**
|
|
742
787
|
* _+M more working…_
|
|
743
788
|
*
|
|
789
|
+
* INDENT: every step line carries a leading `WORKER_STEP_INDENT` (a U+2800 run
|
|
790
|
+
* — neither ASCII spaces nor U+00A0; Telegram left-trims BOTH, see the
|
|
791
|
+
* constant's doc comment) so the steps nest under their worker header and the
|
|
792
|
+
* per-worker blocks are scannable. Header and chrome lines stay at the left
|
|
793
|
+
* margin.
|
|
794
|
+
*
|
|
744
795
|
* NUMBERING (#3298): when the card tracks 2+ rows AND a row carries `ordinal`,
|
|
745
796
|
* its header gets a stable `{ordinal}. ` prefix. Ordinals are assigned by the
|
|
746
797
|
* caller at dispatch and kept for the card's life — after an earlier worker
|
|
@@ -755,6 +806,15 @@ export const workerHistoryDepth = combinedHistoryDepth
|
|
|
755
806
|
* card staying bounded regardless of fan-out. When a worker has no history yet
|
|
756
807
|
* it falls back to a single `→ starting…`/currentStep line.
|
|
757
808
|
*
|
|
809
|
+
* PINNED-BAR READABILITY (#3666): line 1 is a self-contained glance
|
|
810
|
+
* (`glanceLine`) and the stack is joined with `collapseSafe`, because this card
|
|
811
|
+
* is pinned and Telegram's pinned bar renders it collapsed onto ONE line with
|
|
812
|
+
* the newlines dropped and NOTHING substituted. Both halves are needed: the
|
|
813
|
+
* glance so the preview leads with information, the per-line separator so every
|
|
814
|
+
* later seam (`opus 5` → `✓ …` → `→ …`) degrades to a word gap instead of a
|
|
815
|
+
* mashed glyph. There is no Bot API for a separate pin-bar preview — the client
|
|
816
|
+
* derives it from the message text — so the text is the only lever.
|
|
817
|
+
*
|
|
758
818
|
* Pure. Rows are rendered in the order supplied (the manager passes them
|
|
759
819
|
* dispatch-order, oldest first). `maxRows` caps the visible rows; the hidden
|
|
760
820
|
* remainder collapses to a single `+M more working…` line. A total-budget
|
|
@@ -802,13 +862,13 @@ export function renderCombinedWorkerFeed(
|
|
|
802
862
|
// Per-worker depth follows Ken's deterministic curve max(3, 7−w) (#3349):
|
|
803
863
|
// the curve drives DEPTH; the total-line budget below drives ROW COUNT.
|
|
804
864
|
const depth = combinedHistoryDepth(shown.length)
|
|
805
|
-
const chrome: string[] = [
|
|
865
|
+
const chrome: string[] = [glanceLine(rows)]
|
|
806
866
|
const bodyOut: string[] = []
|
|
807
867
|
for (const r of shown) {
|
|
808
868
|
bodyOut.push(rowHeader(r))
|
|
809
869
|
const hist = rowHistory(r)
|
|
810
870
|
if (hist.length === 0) {
|
|
811
|
-
bodyOut.push(
|
|
871
|
+
bodyOut.push(`${WORKER_STEP_INDENT}→ _starting…_`)
|
|
812
872
|
continue
|
|
813
873
|
}
|
|
814
874
|
// Paint the last-K history lines with the SAME `✓`/`→` idiom as the
|
|
@@ -816,12 +876,24 @@ export function renderCombinedWorkerFeed(
|
|
|
816
876
|
// pipeline (escapeStepLine), then renderStepFeed strikes the prior steps
|
|
817
877
|
// and bolds the newest in-progress step. The window equals the depth so a
|
|
818
878
|
// per-worker `+N earlier…` marker never appears inside the combined feed.
|
|
879
|
+
//
|
|
880
|
+
// WORKER_STEP_INDENT nests every step line one level under its worker
|
|
881
|
+
// header, so the boundary between two workers is visible at a glance on a
|
|
882
|
+
// phone (the header lines stay at the left margin, their steps sit in).
|
|
883
|
+
// It is a U+2800 run. NOT ASCII spaces and NOT U+00A0: Telegram's
|
|
884
|
+
// server-side parser left-trims a leading Unicode-whitespace run, so both
|
|
885
|
+
// render flat (#3662 shipped U+00A0 and was inert). U+2800 is category So,
|
|
886
|
+
// not Zs. See the constant's doc comment for the live evidence.
|
|
819
887
|
const esc = hist.slice(-depth).map(escapeStepLine)
|
|
820
|
-
renderStepFeed(bodyOut, esc, false, '', depth)
|
|
888
|
+
renderStepFeed(bodyOut, esc, false, '', depth, WORKER_STEP_INDENT)
|
|
821
889
|
}
|
|
822
890
|
const out = [...chrome, ...bodyOut]
|
|
823
891
|
if (hidden > 0) out.push(`_+${hidden} more working…_`)
|
|
824
|
-
|
|
892
|
+
// collapseSafe (#3666): this card is PINNED, and Telegram's pinned bar
|
|
893
|
+
// renders it collapsed to one line with the newlines dropped and nothing
|
|
894
|
+
// substituted. Without the separator the glance line runs straight into
|
|
895
|
+
// row 1's ordinal and every step glyph mashes into the previous line.
|
|
896
|
+
return { body: stackCardLines(out, { collapseSafe: true }), bodyLines: bodyOut.length }
|
|
825
897
|
}
|
|
826
898
|
|
|
827
899
|
// Cap to maxRows first, then shrink the visible set while EITHER the total
|
|
@@ -53,6 +53,7 @@ import {
|
|
|
53
53
|
cleanWorkerResultParagraph,
|
|
54
54
|
stripMarkdown,
|
|
55
55
|
truncate,
|
|
56
|
+
COLLAPSE_SAFE_SEPARATOR,
|
|
56
57
|
} from './card-format.js'
|
|
57
58
|
import { WORKER_HISTORY_MAX } from './status-no-truncate.js'
|
|
58
59
|
import {
|
|
@@ -142,6 +143,25 @@ export interface BotApiForWorkerFeed {
|
|
|
142
143
|
/** Dispatch-time task description cap for the worker header. */
|
|
143
144
|
const DESC_MAX = 80
|
|
144
145
|
|
|
146
|
+
/**
|
|
147
|
+
* Repeat marker appended to a narrative step that fired more than once in a
|
|
148
|
+
* row (`Running a command ·×3`). Rendered inline on the step line so a worker
|
|
149
|
+
* whose every step carries the SAME label still visibly advances instead of
|
|
150
|
+
* freezing the card. `·` is already the card's separator glyph.
|
|
151
|
+
*/
|
|
152
|
+
const REPEAT_SUFFIX_RE = / ·×(\d+)$/
|
|
153
|
+
|
|
154
|
+
/** The step text without its `·×N` marker (identity for dedup comparisons). */
|
|
155
|
+
export function stripRepeatSuffix(line: string): string {
|
|
156
|
+
return line.replace(REPEAT_SUFFIX_RE, '')
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** How many times a step line has fired: 1 when it carries no marker. */
|
|
160
|
+
export function repeatCountOf(line: string): number {
|
|
161
|
+
const m = REPEAT_SUFFIX_RE.exec(line)
|
|
162
|
+
return m == null ? 1 : Number(m[1])
|
|
163
|
+
}
|
|
164
|
+
|
|
145
165
|
/**
|
|
146
166
|
* Thin adapter over the unified `renderStatusCard` primitive (emoji 🛠, label
|
|
147
167
|
* 'Worker'): builds the header, passes raw narrative steps (the primitive runs
|
|
@@ -226,7 +246,10 @@ export function renderWorkerActivity(v: WorkerActivityView, liveSuffix = ''): st
|
|
|
226
246
|
// Header-only running render → append the starting placeholder with a GFM
|
|
227
247
|
// hard break (` \n`) so it stacks under the header instead of collapsing
|
|
228
248
|
// onto the header line in the rich-message renderer (matches stackCardLines).
|
|
229
|
-
|
|
249
|
+
// The collapse separator is carried here too (#3666) — this card is pinned,
|
|
250
|
+
// and a hand-rolled seam would be the one boundary that still mashed in
|
|
251
|
+
// Telegram's pinned bar.
|
|
252
|
+
return `${card}${COLLAPSE_SAFE_SEPARATOR} \n_starting…_`
|
|
230
253
|
}
|
|
231
254
|
return card
|
|
232
255
|
}
|
|
@@ -428,6 +451,13 @@ interface WorkerRow {
|
|
|
428
451
|
* live render so the feed reads like the main agent's answer.
|
|
429
452
|
*/
|
|
430
453
|
narrative: string[]
|
|
454
|
+
/**
|
|
455
|
+
* `view.toolCount` observed when the newest narrative step was last recorded
|
|
456
|
+
* or counted. The repeat counter (`·×N`) increments only when toolCount has
|
|
457
|
+
* MOVED — the watcher re-emits an unchanged view every tick, so counting
|
|
458
|
+
* label-equality alone would inflate the number with no work behind it.
|
|
459
|
+
*/
|
|
460
|
+
lastNarrativeToolCount: number | null
|
|
431
461
|
/** Last view for this worker (drives the heartbeat re-render + combined row). */
|
|
432
462
|
lastView: WorkerActivityView | null
|
|
433
463
|
/** Latest state observed for this worker; excluded from the running set once
|
|
@@ -794,10 +824,33 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
794
824
|
function accumulateNarrative(row: WorkerRow, view: WorkerActivityView): void {
|
|
795
825
|
const line = view.latestSummary.trim()
|
|
796
826
|
if (line.length === 0) return
|
|
827
|
+
|
|
828
|
+
// A REPEAT of the current step is counted, not dropped. A worker whose
|
|
829
|
+
// steps all render the same label (e.g. Bash calls with no description →
|
|
830
|
+
// "Running a command") used to hit the dedup below on every tool call and
|
|
831
|
+
// the card held ONE frozen line for the whole job — indistinguishable from
|
|
832
|
+
// a wedged worker. `·×N` makes the repetition visible and, because a new
|
|
833
|
+
// count resets stepStartedAtMs, the climbing `· Ns` step timer restarts
|
|
834
|
+
// with each real call.
|
|
835
|
+
//
|
|
836
|
+
// Gated on `view.toolCount`, NOT on the label: the watcher re-emits an
|
|
837
|
+
// UNCHANGED view on every tick, so counting label-equality alone would
|
|
838
|
+
// inflate the number with no work behind it. One increment per observed
|
|
839
|
+
// tool call, deterministically.
|
|
840
|
+
const last = row.narrative.length > 0 ? row.narrative[row.narrative.length - 1] : null
|
|
841
|
+
if (last != null && stripRepeatSuffix(last) === line) {
|
|
842
|
+
if (view.toolCount === row.lastNarrativeToolCount) return
|
|
843
|
+
row.lastNarrativeToolCount = view.toolCount
|
|
844
|
+
row.narrative[row.narrative.length - 1] = `${line} ·×${repeatCountOf(last) + 1}`
|
|
845
|
+
row.stepStartedAtMs = nowFn()
|
|
846
|
+
return
|
|
847
|
+
}
|
|
848
|
+
|
|
797
849
|
// Dedup within the whole rolling window (the watcher re-emits the same
|
|
798
850
|
// narrative across ticks, and a preamble + its tool label can repeat
|
|
799
851
|
// non-adjacently — the A,B,A duplication observed on live cards).
|
|
800
|
-
if (row.narrative.
|
|
852
|
+
if (row.narrative.some((l) => stripRepeatSuffix(l) === line)) return
|
|
853
|
+
row.lastNarrativeToolCount = view.toolCount
|
|
801
854
|
row.narrative.push(line)
|
|
802
855
|
// The `→` current-step line just CHANGED — reset the per-step timer.
|
|
803
856
|
row.stepStartedAtMs = nowFn()
|
|
@@ -1545,6 +1598,7 @@ export function createWorkerActivityFeed(opts: WorkerActivityFeedOpts): WorkerAc
|
|
|
1545
1598
|
agentId,
|
|
1546
1599
|
ordinal: ++g.workerOrdinalCounter,
|
|
1547
1600
|
narrative: [],
|
|
1601
|
+
lastNarrativeToolCount: null,
|
|
1548
1602
|
lastView: null,
|
|
1549
1603
|
state: 'running',
|
|
1550
1604
|
finished: false,
|