dsh-winrm 0.3.1 → 0.4.1

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.
@@ -0,0 +1,335 @@
1
+ /**
2
+ * Transcript cards for `winrm_read`, `winrm_write` and `winrm_edit`.
3
+ *
4
+ * Without these the three tools render as the generic tool row, so the user
5
+ * reads the same envelope text the model reads. Every call becomes a collapsed
6
+ * disclosure with a one-line header — file-type icon, verb, remote path — that
7
+ * expands into a card. The header is drawn from the first dispatch, so settling
8
+ * only fills in the trailing figure instead of replacing the row: the line the
9
+ * user watched while it ran is the line they click to open the result.
10
+ *
11
+ * Collapsed by default is the harness's own convention for these rows: a long
12
+ * file would otherwise fill the transcript before the user asked for it.
13
+ *
14
+ * The header is `DisclosureRow` and the body is `ReadBlock` / `DiffBlock`, all
15
+ * from the harness's published `ui-primitives`, so a remote file looks and
16
+ * behaves exactly like a local one. Open state comes from the `useDisclosure`
17
+ * hook the Tool layer hands every toolview, which also resets it when the
18
+ * enclosing turn re-renders.
19
+ * @module dsh-winrm/src/client/tool/cards
20
+ */
21
+ import type { ReactNode } from 'react'
22
+ import {
23
+ DiffBlock, DisclosureRow, FileTypeIcon, ReadBlock,
24
+ type DiffBlockLabels, type DiffHunk, type ReadBlockLabels,
25
+ } from '@deepseek-ai/dsh-client-ui-primitives'
26
+ import type { ToolCallViewProps } from '@deepseek-ai/dsh-client-ui-tool/client'
27
+ import { tt } from '../panel/helpers.ts'
28
+ import { diffCard, readCard, type DiffCard as DiffCardModel, type ReadCard as ReadCardModel } from './card-model.ts'
29
+ import css from './cards.module.css'
30
+
31
+ /** The Tool view props these cards read. */
32
+ export type WinrmFileCardProps = ToolCallViewProps
33
+
34
+ /**
35
+ * Content lines a transcript card shows before the primitive collapses the
36
+ * middle. Half the primitive's own default (16), because a chat row summarizes
37
+ * inside a message flow while a dedicated panel is the reading surface. Mirrors
38
+ * the harness's `CHAT_READ_MAX_LINES` / `CHAT_DIFF_MAX_LINES`.
39
+ */
40
+ const CHAT_READ_MAX_LINES = 8
41
+ const CHAT_DIFF_MAX_LINES = 9
42
+
43
+ /** Grammar hint from a remote path's extension; unknown extensions stay plain. */
44
+ const LANG_BY_EXTENSION: Readonly<Record<string, string>> = {
45
+ bat: 'bat', cmd: 'bat', ps1: 'powershell', psd1: 'powershell', psm1: 'powershell',
46
+ c: 'c', h: 'c', cpp: 'cpp', cc: 'cpp', hpp: 'cpp', cs: 'csharp',
47
+ css: 'css', scss: 'scss', less: 'less',
48
+ go: 'go', rs: 'rust', java: 'java', kt: 'kotlin', swift: 'swift',
49
+ js: 'javascript', mjs: 'javascript', cjs: 'javascript', jsx: 'javascript',
50
+ ts: 'typescript', mts: 'typescript', cts: 'typescript', tsx: 'typescript',
51
+ json: 'json', jsonc: 'jsonc', yml: 'yaml', yaml: 'yaml', toml: 'toml', ini: 'ini',
52
+ md: 'markdown', markdown: 'markdown', rst: 'rst',
53
+ py: 'python', rb: 'ruby', php: 'php', pl: 'perl', lua: 'lua',
54
+ sh: 'bash', bash: 'bash', zsh: 'bash', fish: 'bash',
55
+ sql: 'sql', xml: 'xml', html: 'html', htm: 'html', vue: 'vue', svelte: 'svelte',
56
+ diff: 'diff', patch: 'diff', csv: 'csv', tsv: 'tsv',
57
+ }
58
+
59
+ /**
60
+ * Guess the grammar hint from a path's extension.
61
+ * @param path - the remote path as authored.
62
+ * @returns the language id, or undefined to keep the block plain.
63
+ */
64
+ function langOf(path: string): string | undefined {
65
+ const dot = path.lastIndexOf('.')
66
+ if (dot < 0) return undefined
67
+ const ext = path.slice(dot + 1).toLowerCase()
68
+ return Object.hasOwn(LANG_BY_EXTENSION, ext) ? LANG_BY_EXTENSION[ext] : undefined
69
+ }
70
+
71
+ /** Localized chrome for {@link ReadBlock}. */
72
+ function readLabels(): ReadBlockLabels {
73
+ return {
74
+ codeLabel: tt('tool.codeLabel'),
75
+ wrapLabel: tt('tool.wrapLabel'),
76
+ unwrapLabel: tt('tool.unwrapLabel'),
77
+ window: (shown, total) => tt('tool.window', { shown, total }),
78
+ copy: tt('tool.copy'),
79
+ copied: tt('tool.copied'),
80
+ collapseAria: tt('tool.collapseAria'),
81
+ expandAria: hidden => tt('tool.expandAria', { hidden }),
82
+ collapse: tt('tool.collapse'),
83
+ expand: hidden => tt('tool.expand', { hidden }),
84
+ }
85
+ }
86
+
87
+ /** Localized chrome for {@link DiffBlock}. */
88
+ function diffLabels(): DiffBlockLabels {
89
+ return {
90
+ codeLabel: tt('tool.codeLabel'),
91
+ wrapLabel: tt('tool.wrapLabel'),
92
+ unwrapLabel: tt('tool.unwrapLabel'),
93
+ copy: tt('tool.copy'),
94
+ copied: tt('tool.copied'),
95
+ collapseAria: tt('tool.collapseAria'),
96
+ expandAria: hidden => tt('tool.expandAria', { hidden }),
97
+ collapse: tt('tool.collapse'),
98
+ expand: hidden => tt('tool.expand', { hidden }),
99
+ }
100
+ }
101
+
102
+ /** One-line row for a call that failed: the message replaces the card. */
103
+ function Row(props: { title: string; detail: string }) {
104
+ return (
105
+ <div className={css.rowError}>
106
+ <span className={css.title}>{props.title}</span>
107
+ {props.detail !== '' && <span className={css.detail}>{props.detail}</span>}
108
+ </div>
109
+ )
110
+ }
111
+
112
+ /** Read the persisted metadata off a settled result node. */
113
+ function metaOf(block: unknown): unknown {
114
+ if (typeof block !== 'object' || block === null) return undefined
115
+ return (block as { meta?: unknown }).meta
116
+ }
117
+
118
+ /**
119
+ * The remote path a call named, read from its arguments.
120
+ *
121
+ * Available from the first dispatch onward, which is what keeps the header line
122
+ * stable across the running → settled transition: the row keeps saying which
123
+ * file it is working on instead of swapping its text when the result lands. A
124
+ * settled node carries the call head under `call`; a running one is the head.
125
+ * @param block - any phase of the Tool block.
126
+ * @returns the authored path, or undefined when none was named yet.
127
+ */
128
+ function argsPath(block: unknown): string | undefined {
129
+ if (typeof block !== 'object' || block === null) return undefined
130
+ const record = block as { argsRaw?: unknown; call?: { argsRaw?: unknown } | null }
131
+ const head = record.call ?? record
132
+ if (typeof head.argsRaw !== 'string' || head.argsRaw === '') return undefined
133
+ let parsed: unknown
134
+ try {
135
+ parsed = JSON.parse(head.argsRaw)
136
+ } catch {
137
+ return undefined
138
+ }
139
+ if (typeof parsed !== 'object' || parsed === null) return undefined
140
+ const path = (parsed as { remotePath?: unknown }).remotePath
141
+ return typeof path === 'string' && path !== '' ? path : undefined
142
+ }
143
+
144
+ /** Count the lines a hunk adds and removes. */
145
+ function changedLines(card: DiffCardModel): { added: number; removed: number } {
146
+ let added = 0
147
+ let removed = 0
148
+ for (const hunk of card.hunks) {
149
+ added += hunk.newText === '' ? 0 : hunk.newText.split('\n').length
150
+ removed += hunk.oldText === '' ? 0 : hunk.oldText.split('\n').length
151
+ }
152
+ return { added, removed }
153
+ }
154
+
155
+ /** The `+n -m` figure a collapsed write or edit row shows beside its path. */
156
+ function changedTotals(card: DiffCardModel): { added: number; removed: number } | undefined {
157
+ const totals = changedLines(card)
158
+ return totals.added === 0 && totals.removed === 0 ? undefined : totals
159
+ }
160
+
161
+ /** The summary line a write or edit card shows under its diff. */
162
+ function diffSummary(card: DiffCardModel): string {
163
+ const parts: string[] = []
164
+ if (card.tool === 'write') {
165
+ parts.push(tt(card.operation === 'create' ? 'tool.created' : 'tool.updated', { bytes: card.bytes }))
166
+ } else {
167
+ parts.push(tt('tool.replacements', { count: card.replacements }))
168
+ }
169
+ if (card.encoding !== 'utf8') parts.push(card.encoding)
170
+ if (card.beyondBasis) parts.push(tt('tool.beyondBasis'))
171
+ return parts.join(' · ')
172
+ }
173
+
174
+ /**
175
+ * One collapsed file row that expands into a card.
176
+ * @param props - the disclosure state, the header material, and the card body.
177
+ * @returns the disclosure row, body included only while expanded.
178
+ */
179
+ function FileDisclosure(props: {
180
+ path: string
181
+ title: string
182
+ meta?: string | undefined
183
+ diff?: { added: number; removed: number } | undefined
184
+ open: boolean
185
+ expandable: boolean
186
+ running?: boolean
187
+ onToggle: () => void
188
+ children: ReactNode
189
+ }) {
190
+ const meta = props.meta ?? ''
191
+ const hasPath = props.path !== ''
192
+ return (
193
+ <div className={css.wrap}>
194
+ <DisclosureRow
195
+ icon={<FileTypeIcon path={props.path} size={14} />}
196
+ title={props.title}
197
+ open={props.open}
198
+ expandable={props.expandable}
199
+ running={props.running ?? false}
200
+ expandOnRowClick={props.expandable}
201
+ onToggle={props.onToggle}
202
+ collapsedContent={hasPath || meta !== '' ? (
203
+ <>
204
+ {hasPath && <span className={css.sep} aria-hidden />}
205
+ {hasPath && <span className={css.summary}>{props.path}</span>}
206
+ {meta !== '' && <span className={css.meta}>{meta}</span>}
207
+ {props.diff !== undefined && (
208
+ <span className={css.diffStat}>
209
+ <span className={css.added}>{`+${props.diff.added}`}</span>{' '}
210
+ <span className={css.removed}>{`-${props.diff.removed}`}</span>
211
+ </span>
212
+ )}
213
+ </>
214
+ ) : undefined}
215
+ >
216
+ {props.children}
217
+ </DisclosureRow>
218
+ </div>
219
+ )
220
+ }
221
+
222
+ /** Render the settled read card. */
223
+ function ReadView(props: { card: ReadCardModel; open: boolean; onToggle: () => void }) {
224
+ const { card, open, onToggle } = props
225
+ const notes: string[] = []
226
+ if (card.truncatedByBytes) notes.push(tt('tool.truncatedByBytes'))
227
+ if (card.truncatedLines > 0) notes.push(tt('tool.truncatedLines', { count: card.truncatedLines }))
228
+ if (card.encoding !== 'utf8') notes.push(card.encoding)
229
+ return (
230
+ <FileDisclosure
231
+ path={card.path}
232
+ title={tt('tool.read')}
233
+ meta={tt('tool.lines', { count: card.totalLines })}
234
+ open={open}
235
+ expandable
236
+ onToggle={onToggle}
237
+ >
238
+ <ReadBlock
239
+ label={card.path}
240
+ lines={card.lines}
241
+ totalLines={card.totalLines}
242
+ lang={langOf(card.path)}
243
+ labels={readLabels()}
244
+ maxLines={CHAT_READ_MAX_LINES}
245
+ />
246
+ {notes.length > 0 && <div className={css.note}>{notes.join(' · ')}</div>}
247
+ </FileDisclosure>
248
+ )
249
+ }
250
+
251
+ /** Render the settled write or edit card. */
252
+ function DiffView(props: { card: DiffCardModel; open: boolean; onToggle: () => void }) {
253
+ const { card, open, onToggle } = props
254
+ // The primitive takes `oldText: null` to mean "no prior content", which is a
255
+ // create; the host writes '' for that case so the two stay JSON-friendly.
256
+ const diffs: DiffHunk[] = card.hunks.map(hunk => ({
257
+ path: card.path,
258
+ oldText: hunk.oldText === '' ? null : hunk.oldText,
259
+ newText: hunk.newText,
260
+ }))
261
+ return (
262
+ <FileDisclosure
263
+ path={card.path}
264
+ title={tt(card.tool === 'write' ? 'tool.write' : 'tool.edit')}
265
+ diff={changedTotals(card)}
266
+ open={open}
267
+ expandable={diffs.length > 0}
268
+ onToggle={onToggle}
269
+ >
270
+ {diffs.length > 0 && (
271
+ <DiffBlock diffs={diffs} labels={diffLabels()} maxLines={CHAT_DIFF_MAX_LINES} />
272
+ )}
273
+ <div className={css.note}>{diffSummary(card)}</div>
274
+ </FileDisclosure>
275
+ )
276
+ }
277
+
278
+ /** Shared driver: one card per stage, with the model built from the metadata. */
279
+ function FileCardView(props: WinrmFileCardProps & { tool: 'read' | 'write' | 'edit' }) {
280
+ const { phase, block, tool, useDisclosure } = props
281
+ const disclosure = useDisclosure()
282
+ const title = tt(`tool.${tool}`)
283
+ const path = argsPath(block) ?? ''
284
+ if (phase !== 'result') {
285
+ return (
286
+ <FileDisclosure
287
+ path={path}
288
+ title={title}
289
+ meta={path === '' ? tt('tool.running') : undefined}
290
+ open={false}
291
+ expandable={false}
292
+ running
293
+ onToggle={() => {}}
294
+ >
295
+ {null}
296
+ </FileDisclosure>
297
+ )
298
+ }
299
+ const meta = metaOf(block)
300
+ const card = tool === 'read' ? readCard(meta, path) : diffCard(meta, path, tool)
301
+ if (card === undefined) {
302
+ return (
303
+ <FileDisclosure
304
+ path={path}
305
+ title={title}
306
+ meta={tt('tool.noDetail')}
307
+ open={false}
308
+ expandable={false}
309
+ onToggle={() => {}}
310
+ >
311
+ {null}
312
+ </FileDisclosure>
313
+ )
314
+ }
315
+ if (card.kind === 'error') return <Row title={title} detail={card.message} />
316
+ if (card.kind === 'read') {
317
+ return <ReadView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
318
+ }
319
+ return <DiffView card={card} open={disclosure.expanded} onToggle={disclosure.toggle} />
320
+ }
321
+
322
+ /** Card for `winrm_read`. */
323
+ export function WinrmReadCard(props: WinrmFileCardProps) {
324
+ return <FileCardView {...props} tool="read" />
325
+ }
326
+
327
+ /** Card for `winrm_write`. */
328
+ export function WinrmWriteCard(props: WinrmFileCardProps) {
329
+ return <FileCardView {...props} tool="write" />
330
+ }
331
+
332
+ /** Card for `winrm_edit`. */
333
+ export function WinrmEditCard(props: WinrmFileCardProps) {
334
+ return <FileCardView {...props} tool="edit" />
335
+ }
package/src/diff.ts ADDED
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Contextual diff derivation for the write/edit presentation metadata.
3
+ *
4
+ * The GUI renders a diff card from `tool/result` metadata rather than from the
5
+ * model-facing text, so the hunks are projected here, at result time, and
6
+ * persisted with the session log. This mirrors the harness's own file tools:
7
+ * one entry per applied change with a few surrounding context lines.
8
+ *
9
+ * The alignment is deliberately cheap rather than a full LCS: common leading
10
+ * and trailing lines are trimmed, and when the remaining halves have the same
11
+ * length each differing run becomes its own hunk. That covers the cases these
12
+ * two tools actually produce — an exact-string edit, and a rewrite that keeps
13
+ * most lines — without a diff dependency in a plugin whose point is a small
14
+ * footprint. A rewrite that changes the line count collapses to one hunk over
15
+ * the changed span, which is still a truthful summary.
16
+ * @module dsh-winrm/src/diff
17
+ */
18
+
19
+ /** Context lines shown on each side of an applied hunk. */
20
+ export const DIFF_CONTEXT = 3
21
+
22
+ /**
23
+ * Largest file whose text may serve as a diff basis. Above this the card shows
24
+ * the path and operation without hunks: fetching and projecting a bigger basis
25
+ * would cost another transfer and a large metadata payload for a summary the
26
+ * user reads at a glance.
27
+ */
28
+ export const DIFF_BASIS_MAX_BYTES = 256 * 1024
29
+
30
+ /**
31
+ * Largest serialized hunk list kept on one result. A pathological edit (a
32
+ * whole-file rewrite of a large file) can produce thousands of hunks; past
33
+ * this bound the card degrades to path-only instead of bloating the log.
34
+ */
35
+ export const DIFF_META_MAX_BYTES = 512 * 1024
36
+
37
+ /**
38
+ * One applied hunk: the fragment before the change and after it.
39
+ *
40
+ * Declared as a type alias rather than an interface so it satisfies the
41
+ * `JsonValue` index signature the tool-output metadata is typed against.
42
+ */
43
+ export type RemoteFileDiff = {
44
+ /** Remote path the hunk belongs to (the authored argument). */
45
+ path: string
46
+ /** Prior content including context lines; empty for a pure insertion. */
47
+ oldText: string
48
+ /** Content after the change, including the shared context lines. */
49
+ newText: string
50
+ }
51
+
52
+ /** Split text into lines, treating every terminator as a separator. */
53
+ function toLines(text: string): string[] {
54
+ return text === '' ? [] : text.split(/\r?\n/)
55
+ }
56
+
57
+ /**
58
+ * Compute the hunks between two texts.
59
+ * @param path - remote path stamped on every hunk.
60
+ * @param before - file text before the change.
61
+ * @param after - file text after the change.
62
+ * @returns one hunk per contiguous change in file order; empty when equal.
63
+ */
64
+ export function computeHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
65
+ if (before === after) return []
66
+ const oldLines = toLines(before)
67
+ const newLines = toLines(after)
68
+
69
+ let head = 0
70
+ while (head < oldLines.length && head < newLines.length && oldLines[head] === newLines[head]) head += 1
71
+ let tail = 0
72
+ while (
73
+ tail < oldLines.length - head
74
+ && tail < newLines.length - head
75
+ && oldLines[oldLines.length - 1 - tail] === newLines[newLines.length - 1 - tail]
76
+ ) tail += 1
77
+
78
+ const oldMid = oldLines.slice(head, oldLines.length - tail)
79
+ const newMid = newLines.slice(head, newLines.length - tail)
80
+
81
+ /** Build one hunk around the changed range `[from, to)` in the middle. */
82
+ const emit = (from: number, to: number): RemoteFileDiff => {
83
+ // Both mid arrays have the same length in the aligned branch below, so one
84
+ // clamped window addresses the old and the new side alike.
85
+ const start = Math.max(0, from - DIFF_CONTEXT)
86
+ const stop = Math.min(oldMid.length, to + DIFF_CONTEXT)
87
+ return {
88
+ path,
89
+ oldText: oldLines.slice(head + start, head + stop).join('\n'),
90
+ newText: newLines.slice(head + start, head + stop).join('\n'),
91
+ }
92
+ }
93
+
94
+ if (oldMid.length !== newMid.length) {
95
+ // Unaligned rewrite: one hunk over the whole changed span.
96
+ return [{
97
+ path,
98
+ oldText: oldLines.slice(Math.max(0, head - DIFF_CONTEXT), oldLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
99
+ newText: newLines.slice(Math.max(0, head - DIFF_CONTEXT), newLines.length - Math.max(0, tail - DIFF_CONTEXT)).join('\n'),
100
+ }]
101
+ }
102
+
103
+ const diffs: RemoteFileDiff[] = []
104
+ let index = 0
105
+ while (index < oldMid.length) {
106
+ if (oldMid[index] === newMid[index]) {
107
+ index += 1
108
+ continue
109
+ }
110
+ const from = index
111
+ while (index < oldMid.length && oldMid[index] !== newMid[index]) index += 1
112
+ diffs.push(emit(from, index))
113
+ }
114
+ return diffs
115
+ }
116
+
117
+ /**
118
+ * Compute hunks and drop them when the serialized payload would exceed the cap.
119
+ * @param path - remote path stamped on every hunk.
120
+ * @param before - prior text.
121
+ * @param after - text after the change.
122
+ * @returns the kept hunks; empty when nothing changed or the payload is too big.
123
+ */
124
+ export function boundedHunkDiffs(path: string, before: string, after: string): RemoteFileDiff[] {
125
+ const diffs = computeHunkDiffs(path, before, after)
126
+ if (diffs.length === 0) return diffs
127
+ return JSON.stringify(diffs).length > DIFF_META_MAX_BYTES ? [] : diffs
128
+ }
@@ -337,6 +337,41 @@ const STREAM_GZIP_MIN_RATIO = 0.85
337
337
  /** Per-Send HTTP budget; a single slice is small, so this only bounds a stalled connection. */
338
338
  const STREAM_SEND_TIMEOUT_MS = 120_000
339
339
 
340
+ /**
341
+ * How long a streaming transfer may deliver nothing before it is treated as
342
+ * stalled.
343
+ *
344
+ * A wall-clock cap on the whole transfer cannot work here: this transport is
345
+ * latency-bound and measured at ~0.73 MiB/s against a real target, so a 700 MiB
346
+ * file legitimately needs about sixteen minutes and any smaller fixed cap fails
347
+ * a transfer that is making steady progress. What a transfer actually needs
348
+ * bounding against is a *stall* — a range that has stopped producing bytes —
349
+ * which is what this idle window measures.
350
+ */
351
+ export const STREAM_IDLE_TIMEOUT_MS = 180_000
352
+
353
+ /**
354
+ * Floor used only to size the runaway backstop, never as a throughput target.
355
+ * Deliberately pessimistic (64 KiB/s): it must not fail a slow link, only a
356
+ * transfer that is somehow still "busy" hours after it should have finished.
357
+ */
358
+ const STREAM_MIN_BYTES_PER_SEC = 65_536
359
+
360
+ /**
361
+ * Budget for one streaming transfer of `bytes` bytes.
362
+ *
363
+ * The idle window is the effective limit; the hard window exists so a wedged
364
+ * transfer cannot stay alive forever, and it scales with the payload because
365
+ * the payload is what sets the legitimate duration.
366
+ * @param bytes - total bytes the transfer must move.
367
+ * @param idleMs - how long the transfer may go without delivering anything.
368
+ * @returns the idle window and the absolute backstop, both in milliseconds.
369
+ */
370
+ export function transferBudget(bytes: number, idleMs = STREAM_IDLE_TIMEOUT_MS): { idleMs: number; hardMs: number } {
371
+ const optimisticMs = Math.ceil(Math.max(0, bytes) / STREAM_MIN_BYTES_PER_SEC) * 1000
372
+ return { idleMs, hardMs: Math.max(idleMs * 4, optimisticMs + idleMs) }
373
+ }
374
+
340
375
  /**
341
376
  * One streaming transfer over a single shell: create → run the receiver →
342
377
  * push the receiver script, the header frame and every data frame down stdin →
@@ -349,7 +384,7 @@ const STREAM_SEND_TIMEOUT_MS = 120_000
349
384
  * @param remotePath - destination path on the target.
350
385
  * @param data - the file contents to send.
351
386
  * @param onProgress - called with cumulative bytes after each frame.
352
- * @param timeoutMs - whole-transfer budget.
387
+ * @param budget - idle window and absolute backstop for the transfer.
353
388
  * @param authMethod - the auth scheme for this attempt.
354
389
  * @param sendHttp - the resolved library send function.
355
390
  * @returns the number of file bytes the receiver acknowledged writing.
@@ -360,7 +395,7 @@ async function streamOnce(
360
395
  remotePath: string,
361
396
  data: Buffer,
362
397
  onProgress: ((transferred: number, total: number) => void) | undefined,
363
- timeoutMs: number,
398
+ budget: { idleMs: number; hardMs: number },
364
399
  authMethod: WinrmAuth,
365
400
  sendHttp: ReturnType<typeof loadSendHttp> & object,
366
401
  ): Promise<number> {
@@ -375,7 +410,12 @@ async function streamOnce(
375
410
  useHttps: conn.useHttps ?? false,
376
411
  rejectUnauthorized: conn.rejectUnauthorized ?? true,
377
412
  }
378
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
413
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
414
+ // Progress is what keeps a transfer alive: the send loop and the final
415
+ // receive loop both refresh this, and only a window with no progress at all
416
+ // is treated as a stall.
417
+ let lastProgressAt = Date.now()
418
+ const stalled = (): boolean => Date.now() - lastProgressAt >= budget.idleMs
379
419
  const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
380
420
  const total = data.length
381
421
 
@@ -405,7 +445,7 @@ async function streamOnce(
405
445
  let slice = STREAM_CHUNK_BYTES
406
446
  let ceiling = STREAM_CHUNK_BYTES
407
447
  while (offset < total) {
408
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
448
+ if (state.settled || stalled()) throw new WinrmTimedOut()
409
449
  const take = Math.min(slice, total - offset)
410
450
  const chunk = data.subarray(offset, offset + take)
411
451
  const gz = gzipSync(chunk, { level: 6 })
@@ -422,6 +462,7 @@ async function streamOnce(
422
462
  throw error
423
463
  }
424
464
  offset += take
465
+ lastProgressAt = Date.now()
425
466
  onProgress?.(offset, total)
426
467
  if (slice < ceiling) slice = Math.min(ceiling, slice * 2)
427
468
  }
@@ -429,15 +470,15 @@ async function streamOnce(
429
470
  // 4. The receiver stops on its own once the declared byte count lands, so
430
471
  // there is nothing to close — just wait for its envelope.
431
472
  const receive = { ...base, shellId, commandId }
432
- const commandDeadline = Date.now() + timeoutMs
433
473
  let stdout = ''
434
474
  let stderr = ''
435
475
  for (;;) {
436
- if (state.settled || Date.now() >= commandDeadline) throw new WinrmTimedOut()
476
+ if (state.settled || stalled()) throw new WinrmTimedOut()
437
477
  const chunk = await Command.doReceiveOutputNonBlocking(receive)
438
478
  stdout += chunk.output
439
479
  stderr += chunk.stderr
440
480
  if (chunk.isComplete) break
481
+ if (chunk.output !== '' || chunk.stderr !== '') lastProgressAt = Date.now()
441
482
  }
442
483
 
443
484
  const parsed = parseEnvelope(stdout)
@@ -488,7 +529,7 @@ async function streamOnce(
488
529
  * @param remotePath - destination path on the target.
489
530
  * @param data - the file contents to send.
490
531
  * @param onProgress - called with cumulative bytes after each frame.
491
- * @param timeoutMs - whole-transfer budget.
532
+ * @param idleMs - how long the transfer may go without moving a slice.
492
533
  * @returns bytes written, or null when the streaming path is unavailable.
493
534
  */
494
535
  export async function uploadStream(
@@ -496,15 +537,16 @@ export async function uploadStream(
496
537
  remotePath: string,
497
538
  data: Buffer,
498
539
  onProgress?: (transferred: number, total: number) => void,
499
- timeoutMs = 600_000,
540
+ idleMs = STREAM_IDLE_TIMEOUT_MS,
500
541
  ): Promise<number | null> {
501
542
  const sendHttp = loadSendHttp()
502
543
  if (sendHttp === null) return null
544
+ const budget = transferBudget(data.length, idleMs)
503
545
  const candidates: WinrmAuth[] = conn.useHttps ? ['basic', 'ntlm'] : ['ntlm', 'basic']
504
546
  let lastError: unknown
505
547
  for (const authMethod of candidates) {
506
548
  try {
507
- return await streamOnce(conn, remotePath, data, onProgress, timeoutMs, authMethod, sendHttp)
549
+ return await streamOnce(conn, remotePath, data, onProgress, budget, authMethod, sendHttp)
508
550
  } catch (error) {
509
551
  if (error instanceof WinrmTimedOut) throw error
510
552
  lastError = error
@@ -527,7 +569,7 @@ const DOWNLOAD_RECEIVE_TIMEOUT_MS = 120_000
527
569
  * @param start - first byte offset of this worker's range.
528
570
  * @param length - bytes this worker must deliver.
529
571
  * @param onData - receives each decompressed piece together with its file offset.
530
- * @param timeoutMs - budget for the whole range.
572
+ * @param budget - idle window and absolute backstop for this worker's range.
531
573
  * @param authMethod - the auth scheme for this attempt.
532
574
  * @param sendHttp - the resolved library HTTP function.
533
575
  * @returns the number of file bytes this worker delivered.
@@ -538,7 +580,7 @@ async function downloadWorkerOnce(
538
580
  start: number,
539
581
  length: number,
540
582
  onData: (offset: number, data: Buffer) => void,
541
- timeoutMs: number,
583
+ budget: { idleMs: number; hardMs: number },
542
584
  authMethod: WinrmAuth,
543
585
  sendHttp: ReturnType<typeof loadSendHttp> & object,
544
586
  ): Promise<number> {
@@ -553,7 +595,12 @@ async function downloadWorkerOnce(
553
595
  useHttps: conn.useHttps ?? false,
554
596
  rejectUnauthorized: conn.rejectUnauthorized ?? true,
555
597
  }
556
- const hardDeadline = Date.now() + timeoutMs + SETUP_GRACE_MS
598
+ const hardDeadline = Date.now() + budget.hardMs + SETUP_GRACE_MS
599
+ // Same rule as the upload path: bytes arriving keep the worker alive, and
600
+ // only a window with no bytes at all counts as a stall. A flat whole-transfer
601
+ // cap would fail a large file on a slow link even while it is moving.
602
+ let lastDataAt = Date.now()
603
+ const stalled = (): boolean => Date.now() - lastDataAt >= budget.idleMs
557
604
  const state = { shellId: undefined as string | undefined, timedOut: false, settled: false }
558
605
 
559
606
  const inner = async (): Promise<number> => {
@@ -589,7 +636,7 @@ async function downloadWorkerOnce(
589
636
  // A worker that lost the deadline race keeps running in the background
590
637
  // unless it checks; without this it would go on fetching and pushing
591
638
  // bytes into a file the caller has already given up on.
592
- if (state.settled || Date.now() >= hardDeadline) throw new WinrmTimedOut()
639
+ if (state.settled || stalled()) throw new WinrmTimedOut()
593
640
  const result = await receiveOutput(target, shellId, commandId, sendHttp, DOWNLOAD_RECEIVE_TIMEOUT_MS)
594
641
  let sawData = false
595
642
  for (const stream of result.streams) {
@@ -605,6 +652,7 @@ async function downloadWorkerOnce(
605
652
  stderr += stream.data.toString('utf8')
606
653
  }
607
654
  }
655
+ if (sawData) lastDataAt = Date.now()
608
656
  if (inflateError !== undefined) throw inflateError
609
657
  if (result.exitCode !== undefined) exitCode = result.exitCode
610
658
  // A finished command whose final response carried nothing means the
@@ -663,7 +711,7 @@ async function downloadWorkerOnce(
663
711
  * @param remotePath - file to read.
664
712
  * @param onData - receives each decompressed piece with its absolute file offset.
665
713
  * @param onProgress - receives cumulative bytes delivered and the total size.
666
- * @param timeoutMs - budget for the whole download.
714
+ * @param idleMs - how long a worker may go without delivering bytes.
667
715
  * @returns the number of bytes delivered, or null when streaming is unavailable.
668
716
  */
669
717
  export async function downloadStream(
@@ -671,13 +719,15 @@ export async function downloadStream(
671
719
  remotePath: string,
672
720
  onData: (offset: number, data: Buffer) => void,
673
721
  onProgress?: (transferred: number, total: number) => void,
674
- timeoutMs = 600_000,
722
+ idleMs = STREAM_IDLE_TIMEOUT_MS,
675
723
  ): Promise<number | null> {
676
724
  const sendHttp = loadSendHttp()
677
725
  if (sendHttp === null) return null
678
726
  const total = await remoteFileSize(conn, remotePath)
679
727
  if (total === null) throw new Error(`remote file not found: ${remotePath}`)
680
728
  if (total === 0) return 0
729
+ // The payload size, not a constant, is what sets a legitimate duration here.
730
+ const budget = transferBudget(total, idleMs)
681
731
  const workers = Math.max(1, Math.min(DOWNLOAD_MAX_WORKERS, Math.ceil(total / DOWNLOAD_MIN_RANGE_BYTES)))
682
732
  const rangeSize = Math.ceil(total / workers)
683
733
  let transferred = 0
@@ -708,7 +758,7 @@ export async function downloadStream(
708
758
  }
709
759
  await Promise.all(
710
760
  ranges.map(range =>
711
- downloadWorkerOnce(conn, remotePath, range.start, range.length, report, timeoutMs, authMethod, sendHttp),
761
+ downloadWorkerOnce(conn, remotePath, range.start, range.length, report, budget, authMethod, sendHttp),
712
762
  ),
713
763
  )
714
764
  return transferred