@agfpd/iapeer 0.4.53 → 0.4.55
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 +1 -1
- package/src/codec/index.ts +44 -2
- package/src/tui/dashboard/app.tsx +23 -3
- package/src/tui/dashboard/run.tsx +16 -4
package/package.json
CHANGED
package/src/codec/index.ts
CHANGED
|
@@ -112,7 +112,10 @@ function indexOfOutsideCdata(buffer: string, needle: string, start: number): num
|
|
|
112
112
|
function readTagContent(xml: string, tag: string): string | undefined {
|
|
113
113
|
const open = `<${tag}>`
|
|
114
114
|
const close = `</${tag}>`
|
|
115
|
-
|
|
115
|
+
// В37 — the OPEN tag must be located CDATA-aware too (the close-side already was): a
|
|
116
|
+
// message whose CDATA body QUOTES `<attachments>…</attachments>` otherwise minted
|
|
117
|
+
// phantom attachments, and a quoted `<message>` corrupted the decoded message.
|
|
118
|
+
const openIdx = indexOfOutsideCdata(xml, open, 0)
|
|
116
119
|
if (openIdx < 0) return undefined
|
|
117
120
|
let i = openIdx + open.length
|
|
118
121
|
let out = ''
|
|
@@ -191,12 +194,36 @@ export function decodeEnvelope(xml: string): IapEnvelope {
|
|
|
191
194
|
}
|
|
192
195
|
}
|
|
193
196
|
|
|
197
|
+
// В38 — a real envelope's open tag is bounded: personality/runtime ≤32 chars each,
|
|
198
|
+
// topic ≤200, intelligence one word, plus attr names and escaping. 1 KiB is far above
|
|
199
|
+
// any legitimate open tag; a longer '>'-less run after `<iap ` is prose, not a tag.
|
|
200
|
+
const MAX_OPEN_TAG_LEN = 1024
|
|
201
|
+
|
|
202
|
+
/** Classify the text at a `<iap ` occurrence: a complete VALID open tag (required attrs
|
|
203
|
+
* present), a complete-but-INVALID one / overlong tagless run (prose that merely contains
|
|
204
|
+
* the marker), or INCOMPLETE (no '>' yet within bounds — wait for the next chunk). */
|
|
205
|
+
function openTagVerdict(candidate: string): 'valid' | 'invalid' | 'incomplete' {
|
|
206
|
+
const m = /^<iap\s+([^>]*)>/.exec(candidate)
|
|
207
|
+
if (!m) {
|
|
208
|
+
const unclosed = candidate.indexOf('>') < 0
|
|
209
|
+
return unclosed && candidate.length <= MAX_OPEN_TAG_LEN ? 'incomplete' : 'invalid'
|
|
210
|
+
}
|
|
211
|
+
if (m[0].length > MAX_OPEN_TAG_LEN) return 'invalid'
|
|
212
|
+
return m[1].includes('from-personality="') && m[1].includes('from-runtime="') ? 'valid' : 'invalid'
|
|
213
|
+
}
|
|
214
|
+
|
|
194
215
|
/**
|
|
195
216
|
* Pull complete `<iap …>…</iap>` envelopes out of a streaming buffer.
|
|
196
217
|
* The closing `</iap>` is located CDATA-aware, so an envelope whose message
|
|
197
218
|
* body contains the literal text `</iap>` is not truncated. `rest` holds the
|
|
198
219
|
* trailing bytes that do not yet form a complete envelope (incl. an envelope
|
|
199
220
|
* still mid-CDATA), to be prepended to the next chunk.
|
|
221
|
+
*
|
|
222
|
+
* В38 — a FALSE start (prose that merely contains `<iap `, e.g. a quoted tool
|
|
223
|
+
* description) is never committed to: the open tag is validated first, and a
|
|
224
|
+
* candidate whose decode fails RESYNCS one char forward instead of being kept
|
|
225
|
+
* (which used to either swallow the NEXT real envelope into a mis-attributed
|
|
226
|
+
* blob, or park the buffer forever waiting for a close that never comes).
|
|
200
227
|
*/
|
|
201
228
|
export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: string } {
|
|
202
229
|
const envelopes: string[] = []
|
|
@@ -208,10 +235,25 @@ export function extractEnvelopes(buffer: string): { envelopes: string[]; rest: s
|
|
|
208
235
|
return { envelopes, rest: rest.slice(Math.max(0, rest.length - '<iap '.length)) }
|
|
209
236
|
}
|
|
210
237
|
if (start > 0) rest = rest.slice(start)
|
|
238
|
+
const verdict = openTagVerdict(rest)
|
|
239
|
+
if (verdict === 'incomplete') return { envelopes, rest } // open tag split across chunks → wait
|
|
240
|
+
if (verdict === 'invalid') {
|
|
241
|
+
rest = rest.slice(1) // false start: skip past this '<' and rescan for the next '<iap '
|
|
242
|
+
continue
|
|
243
|
+
}
|
|
211
244
|
const close = indexOfOutsideCdata(rest, '</iap>', '<iap '.length)
|
|
212
245
|
if (close < 0) return { envelopes, rest } // incomplete (or mid-CDATA) → wait
|
|
213
246
|
const envelopeEnd = close + '</iap>'.length
|
|
214
|
-
|
|
247
|
+
const candidate = rest.slice(0, envelopeEnd)
|
|
248
|
+
try {
|
|
249
|
+
decodeEnvelope(candidate)
|
|
250
|
+
} catch {
|
|
251
|
+
// Structurally envelope-shaped but undecodable → a false start after all;
|
|
252
|
+
// resync so a REAL envelope inside/behind the candidate is still found.
|
|
253
|
+
rest = rest.slice(1)
|
|
254
|
+
continue
|
|
255
|
+
}
|
|
256
|
+
envelopes.push(candidate)
|
|
215
257
|
rest = rest.slice(envelopeEnd)
|
|
216
258
|
}
|
|
217
259
|
}
|
|
@@ -41,6 +41,12 @@ function RuntimeCells({ row }: { row: PeerListing }): React.ReactElement {
|
|
|
41
41
|
)
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Absolute screen reset written on every terminal resize: erase-all + cursor-home.
|
|
45
|
+
* Goes through Ink's OWN write path (useStdout().write → writeToStdout), which
|
|
46
|
+
* erases Ink's frame, writes these bytes, then REPLAYS the last frame with reset
|
|
47
|
+
* erase bookkeeping — so the replay lands at a KNOWN absolute origin (row 1). */
|
|
48
|
+
const RESIZE_SCREEN_RESET = '\x1b[2J\x1b[H'
|
|
49
|
+
|
|
44
50
|
export function DashboardApp({
|
|
45
51
|
env,
|
|
46
52
|
onAction,
|
|
@@ -49,7 +55,7 @@ export function DashboardApp({
|
|
|
49
55
|
onAction: (a: DashAction) => void
|
|
50
56
|
}): React.ReactElement {
|
|
51
57
|
const { exit } = useApp()
|
|
52
|
-
const { stdout } = useStdout()
|
|
58
|
+
const { stdout, write } = useStdout()
|
|
53
59
|
const [rows, setRows] = useState<PeerListing[]>(() => takePeersSnapshot(env))
|
|
54
60
|
const [host, setHost] = useState<HostHeader | null>(null)
|
|
55
61
|
const [now, setNow] = useState<number>(() => Date.now())
|
|
@@ -95,12 +101,26 @@ export function DashboardApp({
|
|
|
95
101
|
}, [env])
|
|
96
102
|
useEffect(() => {
|
|
97
103
|
if (!stdout) return
|
|
98
|
-
const onResize = (): void =>
|
|
104
|
+
const onResize = (): void => {
|
|
105
|
+
// Ink erases frames by RELATIVE cursor math (cursor-up × line-count), and a real
|
|
106
|
+
// terminal displaces alt-screen content on window resize (Apple Terminal shifts it
|
|
107
|
+
// on height changes; a width change can re-wrap) — after which that relative erase
|
|
108
|
+
// misses rows and stale frame fragments survive FOREVER (live incident: a drag-
|
|
109
|
+
// resize stacked 9 footers; Ink itself full-clears only on width SHRINK). So on
|
|
110
|
+
// EVERY resize force an ABSOLUTE reset: wipe the screen + home the cursor, then let
|
|
111
|
+
// Ink replay the frame from that known origin. Written through Ink's write API —
|
|
112
|
+
// never raw to stdout behind its back (the 0.4.51 lesson): writeToStdout keeps its
|
|
113
|
+
// erase model in sync (clear → our bytes → replay with reset line-counts). This
|
|
114
|
+
// listener registers AFTER Ink's own resize handler (mount effect vs render), so
|
|
115
|
+
// the absolute reset is always the LAST word on the resize tick.
|
|
116
|
+
write(RESIZE_SCREEN_RESET)
|
|
117
|
+
setSize({ cols: dim(stdout.columns, 80), rows: dim(stdout.rows, 24) })
|
|
118
|
+
}
|
|
99
119
|
stdout.on('resize', onResize)
|
|
100
120
|
return () => {
|
|
101
121
|
stdout.off('resize', onResize)
|
|
102
122
|
}
|
|
103
|
-
}, [stdout])
|
|
123
|
+
}, [stdout, write])
|
|
104
124
|
|
|
105
125
|
const visible = filterRows(rows, filter)
|
|
106
126
|
const cur = clampCursor(cursor, visible.length)
|
|
@@ -38,13 +38,25 @@ export const DASHBOARD_NOT_INTERACTIVE = -1
|
|
|
38
38
|
/** argv prefix that re-invokes THIS package's CLI (compiled binary by verb / src via
|
|
39
39
|
* bun). Same discriminator as supervisor/index.ts resolveDaemonSelfArgv: `$bunfs` in
|
|
40
40
|
* the module path marks the compiled standalone (existsSync is NO discriminator —
|
|
41
|
-
* Bun intercepts bunfs reads).
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
* Bun intercepts bunfs reads).
|
|
42
|
+
*
|
|
43
|
+
* Discriminate on the MODULE's OWN url — NEVER on a `../..`-navigated path. The
|
|
44
|
+
* compiled bundle lives at `file:///$bunfs/root/<bundle>` (depth 2), so navigating
|
|
45
|
+
* two levels up ESCAPES /$bunfs and URL-normalization silently drops the marker
|
|
46
|
+
* (proven: compiled probe → `new URL('../../cli/index.ts', import.meta.url)` =
|
|
47
|
+
* `/cli/index.ts`). The prior code passed that navigated path here, so the COMPILED
|
|
48
|
+
* dashboard took the src branch and spawned `<binary> /cli/index.ts attach <peer>`
|
|
49
|
+
* → unknown verb → full usage + exit 2 (live incident: Enter→attach dead in
|
|
50
|
+
* `iapeer list`; from-src runs masked it — the navigated path is real there). The
|
|
51
|
+
* cli path is resolved lazily and ONLY on the src branch. */
|
|
52
|
+
export function resolveSelfCliArgv(execPath: string, moduleUrl: string): string[] {
|
|
53
|
+
return moduleUrl.includes('$bunfs')
|
|
54
|
+
? [execPath]
|
|
55
|
+
: [execPath, fileURLToPath(new URL('../../cli/index.ts', moduleUrl))]
|
|
44
56
|
}
|
|
45
57
|
|
|
46
58
|
function selfCliArgv(): string[] {
|
|
47
|
-
return resolveSelfCliArgv(process.execPath,
|
|
59
|
+
return resolveSelfCliArgv(process.execPath, import.meta.url)
|
|
48
60
|
}
|
|
49
61
|
|
|
50
62
|
export interface DashboardOptions {
|