claude-code-rust 0.14.0 → 0.14.2

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.
@@ -11,7 +11,7 @@ import { isToolSearchToolName } from "./tooling.js";
11
11
  import { requestExitPlanModeApproval, requestAskUserQuestionAnswers, EXIT_PLAN_MODE_TOOL_NAME, ASK_USER_QUESTION_TOOL_NAME, } from "./user_interaction.js";
12
12
  import { mapAvailableAgents, emitAvailableAgentsIfChanged, refreshAvailableAgents } from "./agents.js";
13
13
  import { mapSdkSlashCommands, updateAvailableCommands, } from "./available_commands.js";
14
- import { emitAuthRequired, emitFastModeUpdateIfChanged } from "./error_classification.js";
14
+ import { emitAuthRequired, emitFastModeUpdate, setFastModeSnapshotIfChanged, } from "./error_classification.js";
15
15
  import { mapAvailableModels, resolveCurrentModel, currentModelsEqual, } from "./model_metadata.js";
16
16
  import { shouldEmitStartupAuthRequiredForAccount } from "./account_metadata.js";
17
17
  export { mapAvailableModels, resolveCurrentModel } from "./model_metadata.js";
@@ -34,6 +34,7 @@ function permissionDisplayFromCanUseOptions(options) {
34
34
  };
35
35
  }
36
36
  export const sessions = new Map();
37
+ const pendingSessionCloseTasks = new Set();
37
38
  const DEFAULT_SETTING_SOURCES = ["user", "project", "local"];
38
39
  const DEFAULT_PERMISSION_MODE = "default";
39
40
  function isSdkElicitationContentValue(value) {
@@ -100,23 +101,28 @@ function settingsObjectFromLaunchSettings(launchSettings) {
100
101
  return launchSettings.settings;
101
102
  }
102
103
  function normalizedSettingsFromLaunchSettings(launchSettings) {
103
- const settings = settingsObjectFromLaunchSettings(launchSettings);
104
- if (!settings) {
105
- return undefined;
106
- }
104
+ const settings = settingsObjectFromLaunchSettings(launchSettings) ?? {};
105
+ // SendFeedback queues a local draft that can only be reviewed, edited, and
106
+ // discarded through the native /feedback surface. Agent SDK command
107
+ // snapshots do not expose that command to this host, so enabling drafts
108
+ // would create content that claude-rs cannot safely let the user approve.
109
+ const hostSettings = {
110
+ ...settings,
111
+ feedbackDrafts: "off",
112
+ };
107
113
  const sandbox = settings.sandbox && typeof settings.sandbox === "object" && !Array.isArray(settings.sandbox)
108
114
  ? settings.sandbox
109
115
  : undefined;
110
116
  if (sandbox?.enabled === true && sandbox.failIfUnavailable === undefined) {
111
117
  return {
112
- ...settings,
118
+ ...hostSettings,
113
119
  sandbox: {
114
120
  ...sandbox,
115
121
  failIfUnavailable: false,
116
122
  },
117
123
  };
118
124
  }
119
- return settings;
125
+ return hostSettings;
120
126
  }
121
127
  export function sessionById(sessionId) {
122
128
  return sessions.get(sessionId) ?? null;
@@ -129,7 +135,44 @@ export function updateSessionId(session, newSessionId) {
129
135
  session.sessionId = newSessionId;
130
136
  sessions.set(newSessionId, session);
131
137
  }
138
+ export function beginSessionClose(session) {
139
+ session.closing = true;
140
+ for (const monitor of session.mcpAuthMonitors.values()) {
141
+ monitor.controller.abort();
142
+ }
143
+ }
144
+ export function detachSessionForClose(session) {
145
+ beginSessionClose(session);
146
+ if (sessions.get(session.sessionId) === session) {
147
+ sessions.delete(session.sessionId);
148
+ }
149
+ }
150
+ export function trackSessionCloseTask(task) {
151
+ const ownedTask = task.catch((error) => {
152
+ bridgeLogger.error({
153
+ target: LOG_TARGETS.APP_SESSION,
154
+ eventName: "session_close_task_failed",
155
+ message: "background session cleanup failed",
156
+ outcome: "failure",
157
+ fields: {
158
+ error_message: error instanceof Error ? error.message : String(error),
159
+ },
160
+ });
161
+ });
162
+ pendingSessionCloseTasks.add(ownedTask);
163
+ void ownedTask.then(() => {
164
+ pendingSessionCloseTasks.delete(ownedTask);
165
+ });
166
+ }
167
+ async function waitForPendingSessionCloseTasks() {
168
+ while (pendingSessionCloseTasks.size > 0) {
169
+ await Promise.all(Array.from(pendingSessionCloseTasks));
170
+ }
171
+ }
132
172
  export async function closeSession(session) {
173
+ beginSessionClose(session);
174
+ const mcpAuthMonitors = Array.from(session.mcpAuthMonitors.values());
175
+ session.mcpAuthMonitors.clear();
133
176
  session.input.close();
134
177
  session.query.close();
135
178
  for (const pending of session.pendingPermissions.values()) {
@@ -149,6 +192,11 @@ export async function closeSession(session) {
149
192
  pending.resolve({ action: "cancel" });
150
193
  }
151
194
  session.pendingElicitations.clear();
195
+ await Promise.all([
196
+ session.initializationTask,
197
+ session.queryConsumerTask,
198
+ ...mcpAuthMonitors.map((monitor) => monitor.task),
199
+ ].filter((task) => task !== undefined));
152
200
  }
153
201
  export async function closeSessionWithLogging(session, options = {}) {
154
202
  await closeSession(session);
@@ -186,6 +234,7 @@ export async function closeAllSessions(options = {}) {
186
234
  reason: options.reason ?? "bulk_close",
187
235
  requestId: options.requestId,
188
236
  })));
237
+ await waitForPendingSessionCloseTasks();
189
238
  bridgeLogger.info({
190
239
  target: LOG_TARGETS.APP_SESSION,
191
240
  eventName: "all_sessions_closed",
@@ -330,6 +379,7 @@ export async function createSession(params) {
330
379
  query: queryHandle,
331
380
  input,
332
381
  connected: false,
382
+ closing: false,
333
383
  connectEvent: params.connectEvent,
334
384
  connectRequestId: params.requestId,
335
385
  toolCalls: new Map(),
@@ -342,7 +392,9 @@ export async function createSession(params) {
342
392
  pendingUserDialogs: new Map(),
343
393
  pendingElicitations: new Map(),
344
394
  informationalDedupKeys: new Set(),
395
+ knownConnectedMcpServers: new Set(),
345
396
  mcpStatusRevalidatedAt: new Map(),
397
+ mcpAuthMonitors: new Map(),
346
398
  hiddenToolUseIds: new Set(),
347
399
  authHintSent: false,
348
400
  ...(params.resumeUpdates && params.resumeUpdates.length > 0
@@ -391,7 +443,7 @@ export async function createSession(params) {
391
443
  // In stream-input mode the SDK may defer init until input arrives.
392
444
  // Trigger initialization explicitly so the Rust UI can receive `connected`
393
445
  // before the first user prompt.
394
- void session.query
446
+ session.initializationTask = session.query
395
447
  .initializationResult()
396
448
  .then(async (result) => {
397
449
  bridgeLogger.info({
@@ -411,6 +463,7 @@ export async function createSession(params) {
411
463
  const currentModelChanged = refreshCurrentModel(session);
412
464
  const { buildModeState, refreshSupportedModesForSession } = await import("./commands.js");
413
465
  refreshSupportedModesForSession(session);
466
+ const fastModeChanged = setFastModeSnapshotIfChanged(session, result.fast_mode_state, result.fast_mode_disabled_reason);
414
467
  if (!session.connected) {
415
468
  emitConnectEvent(session);
416
469
  }
@@ -424,13 +477,15 @@ export async function createSession(params) {
424
477
  mode: buildModeState(session, session.mode),
425
478
  });
426
479
  }
480
+ if (fastModeChanged) {
481
+ emitFastModeUpdate(session);
482
+ }
427
483
  }
428
484
  // Proactively detect missing auth from account info so the UI can
429
485
  // show the login hint immediately, without waiting for the first prompt.
430
486
  if (shouldEmitStartupAuthRequiredForAccount(result.account)) {
431
487
  emitAuthRequired(session);
432
488
  }
433
- emitFastModeUpdateIfChanged(session, result.fast_mode_state);
434
489
  updateAvailableCommands(session, "session_result_commands", mapSdkSlashCommands(result.commands));
435
490
  emitAvailableAgentsIfChanged(session, mapAvailableAgents(result.agents));
436
491
  refreshAvailableAgents(session);
@@ -452,7 +507,7 @@ export async function createSession(params) {
452
507
  failConnection(`agent initialization failed: ${message}`, session.connectRequestId);
453
508
  session.connectRequestId = undefined;
454
509
  });
455
- void (async () => {
510
+ session.queryConsumerTask = (async () => {
456
511
  try {
457
512
  for await (const message of session.query) {
458
513
  // Lazy import to break circular dependency at module-evaluation time.
@@ -612,9 +667,13 @@ export function buildQueryOptions(params) {
612
667
  includePartialMessages: true,
613
668
  promptSuggestions: true,
614
669
  enableFileCheckpointing: true,
670
+ // ProposeSkills reports only a proposal count and expects a native review
671
+ // surface. Keep it out of this host until claude-rs can display the actual
672
+ // proposal input and accept/reject it without losing content.
673
+ disallowedTools: ["ProposeSkills"],
615
674
  executable: "bun",
616
675
  ...(params.resume ? {} : { sessionId: params.provisionalSessionId }),
617
- ...(settings ? { settings } : {}),
676
+ settings,
618
677
  ...modelOption,
619
678
  ...permissionModeOptions,
620
679
  toolConfig: { askUserQuestion: { previewFormat: "markdown" } },
@@ -15,12 +15,47 @@ function nonNegativeNumberField(record, ...keys) {
15
15
  }
16
16
  return value;
17
17
  }
18
+ function nonNegativeIntegerField(record, ...keys) {
19
+ const value = numberField(record, ...keys);
20
+ return value !== undefined && value >= 0 && Number.isInteger(value) ? value : undefined;
21
+ }
22
+ export function buildSubagentRetryUpdate(message) {
23
+ const retry = asRecordOrNull(message.subagent_retry);
24
+ if (!retry) {
25
+ return null;
26
+ }
27
+ const attempt = nonNegativeIntegerField(retry, "attempt");
28
+ const maxRetries = nonNegativeIntegerField(retry, "max_retries", "maxRetries");
29
+ const retryDelayMs = nonNegativeIntegerField(retry, "retry_delay_ms", "retryDelayMs");
30
+ if (attempt === undefined || maxRetries === undefined || retryDelayMs === undefined) {
31
+ return null;
32
+ }
33
+ const agentId = typeof retry.agent_id === "string" ? retry.agent_id.trim() : "";
34
+ const errorCategory = typeof retry.error_category === "string" ? retry.error_category.trim() : "";
35
+ const errorStatus = nonNegativeIntegerField(retry, "error_status", "errorStatus");
36
+ return {
37
+ state: "waiting",
38
+ ...(agentId ? { agent_id: agentId } : {}),
39
+ attempt,
40
+ max_retries: maxRetries,
41
+ retry_delay_ms: retryDelayMs,
42
+ ...(errorStatus !== undefined ? { error_status: errorStatus } : {}),
43
+ ...(errorCategory ? { error_category: errorCategory } : {}),
44
+ };
45
+ }
18
46
  export function parseFastModeState(value) {
19
47
  if (value === "off" || value === "cooldown" || value === "on") {
20
48
  return value;
21
49
  }
22
50
  return null;
23
51
  }
52
+ export function parseFastModeDisabledReason(value) {
53
+ if (typeof value !== "string") {
54
+ return undefined;
55
+ }
56
+ const reason = value.trim();
57
+ return reason.length > 0 ? reason : undefined;
58
+ }
24
59
  export function parseRateLimitStatus(value) {
25
60
  if (value === "allowed" || value === "allowed_warning" || value === "rejected") {
26
61
  return value;
@@ -3,7 +3,7 @@ import { bridgeLogger, LOG_TARGETS } from "./logger.js";
3
3
  import { asRecordOrNull } from "./shared.js";
4
4
  import { applyTaskToolResult } from "./tasks.js";
5
5
  import { activeTaskIdForToolUse, linkTaskToolUse, unlinkTaskToolUse } from "./task_links.js";
6
- import { backgroundToolLaunchTaskIdFromResult, buildToolResultFields, createToolCall, } from "./tooling.js";
6
+ import { applyToolNonExecutionMetadata, backgroundToolLaunchTaskIdFromResult, buildToolResultFields, createToolCall, } from "./tooling.js";
7
7
  const TOOL_SUMMARY_TOOL_NAMES = new Set(["Agent", "Task", "WebSearch", "WebFetch", "ExitPlanMode"]);
8
8
  const TASK_LIFECYCLE_TOOL_NAMES = new Set(["Agent", "Task", "Monitor", "Workflow"]);
9
9
  function jsonSize(value) {
@@ -112,10 +112,14 @@ function mergeTaskMetadata(current, update) {
112
112
  if (update === undefined) {
113
113
  return current;
114
114
  }
115
- return {
115
+ const merged = {
116
116
  ...(current ?? {}),
117
117
  ...update,
118
118
  };
119
+ if (update.subagent_retry?.state === "clear") {
120
+ delete merged.subagent_retry;
121
+ }
122
+ return merged;
119
123
  }
120
124
  function applyFieldsToBase(base, fields) {
121
125
  if (fields.title !== undefined) {
@@ -237,6 +241,16 @@ function taskTitleContext(session, name, input) {
237
241
  }
238
242
  export function emitToolCallUpdate(session, toolUseId, fields, updateKind, sourceMessageUuid) {
239
243
  const base = session.toolCalls.get(toolUseId);
244
+ const nextStatus = fields.status ?? base?.status;
245
+ const terminal = nextStatus === "completed" || nextStatus === "failed" || nextStatus === "killed";
246
+ const hasActiveRetry = base?.task_metadata?.subagent_retry?.state === "waiting" ||
247
+ fields.task_metadata?.subagent_retry?.state === "waiting";
248
+ if (terminal && hasActiveRetry) {
249
+ fields.task_metadata = {
250
+ ...(fields.task_metadata ?? {}),
251
+ subagent_retry: { state: "clear" },
252
+ };
253
+ }
240
254
  logToolCallUpdateEmitted(session.sessionId, toolUseId, fields, base, updateKind);
241
255
  emitSessionUpdate(session.sessionId, {
242
256
  type: "tool_call_update",
@@ -294,18 +308,19 @@ export function ensureToolCallVisible(session, toolUseId, toolName, input, paren
294
308
  emitInitialToolCall(session, toolCall);
295
309
  return toolCall;
296
310
  }
297
- export function emitToolResultUpdate(session, toolUseId, isError, rawContent, rawResult = rawContent, sourceMessageUuid) {
311
+ export function emitToolResultUpdate(session, toolUseId, isError, rawContent, rawResult = rawContent, sourceMessageUuid, nonExecutionMetadata) {
298
312
  const base = session.toolCalls.get(toolUseId);
299
313
  const baseToolName = toolNameFromMeta(base?.meta) ?? "";
300
314
  const fields = buildToolResultFields(isError, rawContent, base, rawResult, taskTitleContext(session, baseToolName, asRecordOrNull(base?.raw_input) ?? {}));
301
- if (!isError) {
315
+ applyToolNonExecutionMetadata(fields, nonExecutionMetadata);
316
+ if (!isError && !nonExecutionMetadata) {
302
317
  const taskId = backgroundToolLaunchTaskIdFromResult(baseToolName, rawResult, rawContent);
303
318
  if (taskId) {
304
319
  linkTaskToolUse(session, taskId, toolUseId);
305
320
  }
306
321
  }
307
322
  emitToolCallUpdate(session, toolUseId, fields, "result", sourceMessageUuid);
308
- applyTaskToolResult(session, toolUseId, isError, rawContent, rawResult);
323
+ applyTaskToolResult(session, toolUseId, isError || nonExecutionMetadata !== undefined, rawContent, rawResult);
309
324
  if (baseToolName === "Agent" || baseToolName === "Task") {
310
325
  const taskId = activeTaskIdForToolUse(session, toolUseId);
311
326
  if (taskId) {
@@ -324,19 +339,39 @@ export function finalizeOpenToolCalls(session, status) {
324
339
  emitToolCallUpdate(session, toolUseId, { status }, "finalize");
325
340
  }
326
341
  }
327
- export function emitToolProgressUpdate(session, toolUseId, toolName) {
328
- const existing = session.toolCalls.get(toolUseId);
342
+ export function emitToolProgressUpdate(session, toolUseId, toolName, progress = {}) {
343
+ let existing = session.toolCalls.get(toolUseId);
329
344
  if (!existing) {
330
345
  emitToolCall(session, toolUseId, toolName, {});
331
- return;
346
+ existing = session.toolCalls.get(toolUseId);
332
347
  }
333
- if (existing.status === "in_progress" ||
348
+ if (!existing ||
334
349
  existing.status === "completed" ||
335
350
  existing.status === "failed" ||
336
351
  existing.status === "killed") {
337
352
  return;
338
353
  }
339
- emitToolCallUpdate(session, toolUseId, { status: "in_progress" }, "progress");
354
+ const taskMetadata = {};
355
+ if (progress.subagentRetry?.state === "waiting") {
356
+ taskMetadata.subagent_retry = progress.subagentRetry;
357
+ }
358
+ else if (progress.subagentRetry?.state === "clear" &&
359
+ existing.task_metadata?.subagent_retry?.state === "waiting") {
360
+ taskMetadata.subagent_retry = progress.subagentRetry;
361
+ }
362
+ if (progress.subagentType && progress.subagentType !== existing.task_metadata?.subagent_type) {
363
+ taskMetadata.subagent_type = progress.subagentType;
364
+ }
365
+ const fields = {};
366
+ if (existing.status !== "in_progress") {
367
+ fields.status = "in_progress";
368
+ }
369
+ if (Object.keys(taskMetadata).length > 0) {
370
+ fields.task_metadata = taskMetadata;
371
+ }
372
+ if (Object.keys(fields).length > 0) {
373
+ emitToolCallUpdate(session, toolUseId, fields, "progress");
374
+ }
340
375
  }
341
376
  export function emitToolSummaryUpdate(session, toolUseId, summary) {
342
377
  const base = session.toolCalls.get(toolUseId);
@@ -482,9 +482,19 @@ function extractToolOutputMetadata(toolName, rawResult, rawContent) {
482
482
  if (toolName === "Bash") {
483
483
  for (const candidate of candidates) {
484
484
  const hasAssistantAutoBackgrounded = typeof candidate.assistantAutoBackgrounded === "boolean";
485
- if (hasAssistantAutoBackgrounded) {
485
+ const timedOutAfterMs = nonNegativeInteger(candidate.timedOutAfterMs);
486
+ const backgroundCwdHint = nonEmptyString(candidate.backgroundCwdHint);
487
+ if (hasAssistantAutoBackgrounded || timedOutAfterMs !== undefined || backgroundCwdHint) {
486
488
  const bashMetadata = {};
487
- bashMetadata.assistant_auto_backgrounded = candidate.assistantAutoBackgrounded;
489
+ if (hasAssistantAutoBackgrounded) {
490
+ bashMetadata.assistant_auto_backgrounded = candidate.assistantAutoBackgrounded;
491
+ }
492
+ if (timedOutAfterMs !== undefined) {
493
+ bashMetadata.timed_out_after_ms = timedOutAfterMs;
494
+ }
495
+ if (backgroundCwdHint) {
496
+ bashMetadata.background_cwd_hint = backgroundCwdHint;
497
+ }
488
498
  metadata.bash = bashMetadata;
489
499
  break;
490
500
  }
@@ -493,9 +503,11 @@ function extractToolOutputMetadata(toolName, rawResult, rawContent) {
493
503
  if (toolName === "Agent" || toolName === "Task") {
494
504
  for (const candidate of candidates) {
495
505
  const resolvedModel = nonEmptyString(candidate.resolvedModel);
496
- if (resolvedModel) {
506
+ const modelsUsed = orderedNonEmptyStrings(candidate.modelsUsed);
507
+ if (resolvedModel || modelsUsed) {
497
508
  const agentMetadata = {
498
- resolved_model: resolvedModel,
509
+ ...(resolvedModel ? { resolved_model: resolvedModel } : {}),
510
+ ...(modelsUsed ? { models_used: modelsUsed } : {}),
499
511
  };
500
512
  metadata.agent = agentMetadata;
501
513
  break;
@@ -516,7 +528,59 @@ function extractToolOutputMetadata(toolName, rawResult, rawContent) {
516
528
  }
517
529
  }
518
530
  }
519
- return metadata.bash || metadata.agent || metadata.web_fetch ? metadata : undefined;
531
+ if (toolName === "Skill") {
532
+ for (const candidate of candidates) {
533
+ if (candidate.background === true) {
534
+ metadata.skill = { background: true };
535
+ break;
536
+ }
537
+ }
538
+ }
539
+ return metadata.bash || metadata.agent || metadata.web_fetch || metadata.skill
540
+ ? metadata
541
+ : undefined;
542
+ }
543
+ export function parseToolNonExecutionMetadata(value) {
544
+ const byToolUseId = new Map();
545
+ if (!Array.isArray(value)) {
546
+ return byToolUseId;
547
+ }
548
+ for (const entry of value) {
549
+ const record = asRecordOrNull(entry);
550
+ const id = nonEmptyString(record?.id);
551
+ const kind = nonEmptyString(record?.non_execution_kind);
552
+ if (!id || !kind || byToolUseId.has(id)) {
553
+ continue;
554
+ }
555
+ const userFeedback = nonEmptyString(record?.user_feedback);
556
+ byToolUseId.set(id, {
557
+ kind,
558
+ ...(userFeedback ? { user_feedback: userFeedback } : {}),
559
+ });
560
+ }
561
+ return byToolUseId;
562
+ }
563
+ export function applyToolNonExecutionMetadata(fields, metadata) {
564
+ if (!metadata) {
565
+ return;
566
+ }
567
+ fields.output_metadata = {
568
+ ...(fields.output_metadata ?? {}),
569
+ non_execution: metadata,
570
+ };
571
+ switch (metadata.kind) {
572
+ case "user-rejected":
573
+ case "permission-rule":
574
+ case "automode-unavailable":
575
+ case "automode-parsing-error":
576
+ case "automode-blocked":
577
+ fields.status = "failed";
578
+ break;
579
+ case "cancelled":
580
+ case "interrupted":
581
+ fields.status = "killed";
582
+ break;
583
+ }
520
584
  }
521
585
  export function extractText(value) {
522
586
  if (typeof value === "string") {
@@ -736,6 +800,10 @@ function shellBackgroundMessage(record) {
736
800
  if (!backgroundTaskId) {
737
801
  return "";
738
802
  }
803
+ const timedOutAfterMs = nonNegativeInteger(record.timedOutAfterMs);
804
+ if (timedOutAfterMs !== undefined) {
805
+ return `Command was auto-backgrounded after ${timedOutAfterMs.toLocaleString("en-US")} ms with ID: ${backgroundTaskId}.`;
806
+ }
739
807
  if (record.assistantAutoBackgrounded === true) {
740
808
  return `Command was auto-backgrounded by assistant mode with ID: ${backgroundTaskId}.`;
741
809
  }
@@ -812,6 +880,9 @@ function recordNumber(record, key) {
812
880
  const value = record[key];
813
881
  return typeof value === "number" && Number.isFinite(value) ? value : undefined;
814
882
  }
883
+ function recordNonNegativeInteger(record, key) {
884
+ return nonNegativeInteger(record[key]);
885
+ }
815
886
  function recordString(record, key) {
816
887
  const value = record[key];
817
888
  return typeof value === "string" ? value : undefined;
@@ -857,8 +928,13 @@ function globResultText(record) {
857
928
  function grepResultText(record) {
858
929
  const filenames = searchFilenames(record);
859
930
  const content = recordString(record, "content") ?? "";
860
- const numFiles = recordNumber(record, "numFiles") ?? filenames.length;
861
- const numLines = recordNumber(record, "numLines");
931
+ const hasVisibleMatches = content.trim().length > 0 || filenames.length > 0;
932
+ const totalFiles = recordNonNegativeInteger(record, "totalFiles");
933
+ const legacyNumFiles = recordNonNegativeInteger(record, "numFiles");
934
+ const numFiles = totalFiles ??
935
+ (legacyNumFiles !== 0 || !hasVisibleMatches ? legacyNumFiles : undefined) ??
936
+ (filenames.length > 0 ? filenames.length : undefined);
937
+ const numLines = recordNonNegativeInteger(record, "totalLines") ?? recordNonNegativeInteger(record, "numLines");
862
938
  const numMatches = recordNumber(record, "numMatches");
863
939
  const appliedLimit = recordNumber(record, "appliedLimit");
864
940
  const appliedOffset = recordNumber(record, "appliedOffset");
@@ -878,7 +954,9 @@ function grepResultText(record) {
878
954
  lines.push("No matches found");
879
955
  }
880
956
  const summaryParts = [];
881
- summaryParts.push(`${numFiles} ${pluralize(numFiles, "file")}`);
957
+ if (numFiles !== undefined) {
958
+ summaryParts.push(`${numFiles} ${pluralize(numFiles, "file")}`);
959
+ }
882
960
  if (numMatches !== undefined) {
883
961
  summaryParts.push(`${numMatches} ${pluralize(numMatches, "match", "matches")}`);
884
962
  }
@@ -937,6 +1015,26 @@ function pushBooleanField(lines, label, value) {
937
1015
  function nonEmptyString(value) {
938
1016
  return typeof value === "string" && value.trim() ? value.trim() : undefined;
939
1017
  }
1018
+ function nonNegativeInteger(value) {
1019
+ return typeof value === "number" && Number.isFinite(value) && value >= 0
1020
+ ? Math.trunc(value)
1021
+ : undefined;
1022
+ }
1023
+ function orderedNonEmptyStrings(value) {
1024
+ if (!Array.isArray(value)) {
1025
+ return undefined;
1026
+ }
1027
+ const seen = new Set();
1028
+ const values = [];
1029
+ for (const item of value) {
1030
+ const normalized = nonEmptyString(item);
1031
+ if (normalized && !seen.has(normalized)) {
1032
+ seen.add(normalized);
1033
+ values.push(normalized);
1034
+ }
1035
+ }
1036
+ return values.length > 0 ? values : undefined;
1037
+ }
940
1038
  const CRON_MONTH_NAMES = [
941
1039
  "January",
942
1040
  "February",