gsd-pi 2.70.1-dev.3e19108 → 2.70.1-dev.7d1d9d3

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.
Files changed (106) hide show
  1. package/dist/resources/extensions/claude-code-cli/stream-adapter.js +127 -30
  2. package/dist/resources/extensions/get-secrets-from-user.js +17 -1
  3. package/dist/resources/extensions/gsd/custom-workflow-engine.js +16 -12
  4. package/dist/resources/extensions/gsd/file-lock.js +60 -0
  5. package/dist/resources/extensions/gsd/state.js +234 -332
  6. package/dist/resources/extensions/gsd/workflow-events.js +25 -13
  7. package/dist/web/standalone/.next/BUILD_ID +1 -1
  8. package/dist/web/standalone/.next/app-path-routes-manifest.json +15 -15
  9. package/dist/web/standalone/.next/build-manifest.json +2 -2
  10. package/dist/web/standalone/.next/prerender-manifest.json +3 -3
  11. package/dist/web/standalone/.next/server/app/_global-error.html +1 -1
  12. package/dist/web/standalone/.next/server/app/_global-error.rsc +1 -1
  13. package/dist/web/standalone/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
  14. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error/__PAGE__.segment.rsc +1 -1
  15. package/dist/web/standalone/.next/server/app/_global-error.segments/_global-error.segment.rsc +1 -1
  16. package/dist/web/standalone/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
  17. package/dist/web/standalone/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
  18. package/dist/web/standalone/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
  19. package/dist/web/standalone/.next/server/app/_not-found.html +1 -1
  20. package/dist/web/standalone/.next/server/app/_not-found.rsc +1 -1
  21. package/dist/web/standalone/.next/server/app/_not-found.segments/_full.segment.rsc +1 -1
  22. package/dist/web/standalone/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
  23. package/dist/web/standalone/.next/server/app/_not-found.segments/_index.segment.rsc +1 -1
  24. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
  25. package/dist/web/standalone/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
  26. package/dist/web/standalone/.next/server/app/_not-found.segments/_tree.segment.rsc +1 -1
  27. package/dist/web/standalone/.next/server/app/index.html +1 -1
  28. package/dist/web/standalone/.next/server/app/index.rsc +1 -1
  29. package/dist/web/standalone/.next/server/app/index.segments/__PAGE__.segment.rsc +1 -1
  30. package/dist/web/standalone/.next/server/app/index.segments/_full.segment.rsc +1 -1
  31. package/dist/web/standalone/.next/server/app/index.segments/_head.segment.rsc +1 -1
  32. package/dist/web/standalone/.next/server/app/index.segments/_index.segment.rsc +1 -1
  33. package/dist/web/standalone/.next/server/app/index.segments/_tree.segment.rsc +1 -1
  34. package/dist/web/standalone/.next/server/app-paths-manifest.json +15 -15
  35. package/dist/web/standalone/.next/server/middleware-build-manifest.js +1 -1
  36. package/dist/web/standalone/.next/server/pages/404.html +1 -1
  37. package/dist/web/standalone/.next/server/pages/500.html +1 -1
  38. package/dist/web/standalone/.next/server/server-reference-manifest.json +1 -1
  39. package/package.json +1 -1
  40. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js +256 -1
  41. package/packages/pi-coding-agent/dist/core/chat-controller-ordering.test.js.map +1 -1
  42. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts +2 -0
  43. package/packages/pi-coding-agent/dist/core/extensions/types.d.ts.map +1 -1
  44. package/packages/pi-coding-agent/dist/core/extensions/types.js.map +1 -1
  45. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts +19 -2
  46. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.d.ts.map +1 -1
  47. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js +50 -1
  48. package/packages/pi-coding-agent/dist/modes/interactive/components/dynamic-border.js.map +1 -1
  49. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts +1 -0
  50. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.d.ts.map +1 -1
  51. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js +1 -0
  52. package/packages/pi-coding-agent/dist/modes/interactive/components/extension-input.js.map +1 -1
  53. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.d.ts.map +1 -1
  54. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js +117 -9
  55. package/packages/pi-coding-agent/dist/modes/interactive/controllers/chat-controller.js.map +1 -1
  56. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts +1 -0
  57. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.d.ts.map +1 -1
  58. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode-state.js.map +1 -1
  59. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts +6 -0
  60. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  61. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js +58 -2
  62. package/packages/pi-coding-agent/dist/modes/interactive/interactive-mode.js.map +1 -1
  63. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js +1 -1
  64. package/packages/pi-coding-agent/dist/modes/rpc/rpc-mode.js.map +1 -1
  65. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts +1 -0
  66. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  67. package/packages/pi-coding-agent/dist/modes/rpc/rpc-types.js.map +1 -1
  68. package/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +317 -1
  69. package/packages/pi-coding-agent/src/core/extensions/types.ts +2 -0
  70. package/packages/pi-coding-agent/src/modes/interactive/components/dynamic-border.ts +58 -2
  71. package/packages/pi-coding-agent/src/modes/interactive/components/extension-input.ts +2 -0
  72. package/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +128 -15
  73. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode-state.ts +1 -0
  74. package/packages/pi-coding-agent/src/modes/interactive/interactive-mode.ts +66 -2
  75. package/packages/pi-coding-agent/src/modes/rpc/rpc-mode.ts +1 -1
  76. package/packages/pi-coding-agent/src/modes/rpc/rpc-types.ts +1 -0
  77. package/packages/pi-tui/dist/components/__tests__/input.test.js +9 -0
  78. package/packages/pi-tui/dist/components/__tests__/input.test.js.map +1 -1
  79. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts +2 -0
  80. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.d.ts.map +1 -0
  81. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js +66 -0
  82. package/packages/pi-tui/dist/components/__tests__/markdown-maxlines.test.js.map +1 -0
  83. package/packages/pi-tui/dist/components/input.d.ts +2 -0
  84. package/packages/pi-tui/dist/components/input.d.ts.map +1 -1
  85. package/packages/pi-tui/dist/components/input.js +7 -4
  86. package/packages/pi-tui/dist/components/input.js.map +1 -1
  87. package/packages/pi-tui/dist/components/markdown.d.ts +3 -0
  88. package/packages/pi-tui/dist/components/markdown.d.ts.map +1 -1
  89. package/packages/pi-tui/dist/components/markdown.js +17 -1
  90. package/packages/pi-tui/dist/components/markdown.js.map +1 -1
  91. package/packages/pi-tui/src/components/__tests__/input.test.ts +11 -0
  92. package/packages/pi-tui/src/components/__tests__/markdown-maxlines.test.ts +75 -0
  93. package/packages/pi-tui/src/components/input.ts +7 -4
  94. package/packages/pi-tui/src/components/markdown.ts +22 -1
  95. package/src/resources/extensions/claude-code-cli/stream-adapter.ts +164 -31
  96. package/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +112 -0
  97. package/src/resources/extensions/get-secrets-from-user.ts +24 -1
  98. package/src/resources/extensions/gsd/custom-workflow-engine.ts +19 -14
  99. package/src/resources/extensions/gsd/file-lock.ts +59 -0
  100. package/src/resources/extensions/gsd/state.ts +274 -344
  101. package/src/resources/extensions/gsd/tests/derive-state-helpers.test.ts +436 -0
  102. package/src/resources/extensions/gsd/tests/file-lock.test.ts +103 -0
  103. package/src/resources/extensions/gsd/tests/secure-env-collect.test.ts +45 -0
  104. package/src/resources/extensions/gsd/workflow-events.ts +34 -25
  105. /package/dist/web/standalone/.next/static/{cHCEWiRJM5bXJa9HkP1QU → 52NuiWbmUzXpzxaTEDopT}/_buildManifest.js +0 -0
  106. /package/dist/web/standalone/.next/static/{cHCEWiRJM5bXJa9HkP1QU → 52NuiWbmUzXpzxaTEDopT}/_ssgManifest.js +0 -0
@@ -60,6 +60,8 @@ interface SdkElicitationFieldSchema {
60
60
  type?: string;
61
61
  title?: string;
62
62
  description?: string;
63
+ format?: string;
64
+ writeOnly?: boolean;
63
65
  oneOf?: SdkElicitationRequestOption[];
64
66
  items?: {
65
67
  anyOf?: SdkElicitationRequestOption[];
@@ -73,6 +75,7 @@ interface SdkElicitationRequest {
73
75
  requestedSchema?: {
74
76
  type?: string;
75
77
  properties?: Record<string, SdkElicitationFieldSchema>;
78
+ required?: string[];
76
79
  };
77
80
  }
78
81
 
@@ -85,7 +88,16 @@ interface ParsedElicitationQuestion extends Question {
85
88
  noteFieldId?: string;
86
89
  }
87
90
 
91
+ interface ParsedTextInputField {
92
+ id: string;
93
+ title: string;
94
+ description: string;
95
+ required: boolean;
96
+ secure: boolean;
97
+ }
98
+
88
99
  const OTHER_OPTION_LABEL = "None of the above";
100
+ const SENSITIVE_FIELD_PATTERN = /(password|passphrase|secret|token|api[_\s-]*key|private[_\s-]*key|credential)/i;
89
101
 
90
102
  // ---------------------------------------------------------------------------
91
103
  // Stream factory
@@ -274,6 +286,67 @@ export function parseAskUserQuestionsElicitation(
274
286
  return questions.length > 0 ? questions : null;
275
287
  }
276
288
 
289
+ function isSecureElicitationField(
290
+ requestMessage: string,
291
+ fieldId: string,
292
+ field: SdkElicitationFieldSchema,
293
+ ): boolean {
294
+ if (field.format === "password") return true;
295
+ if (field.writeOnly === true) return true;
296
+
297
+ const rawField = field as Record<string, unknown>;
298
+ if (rawField.sensitive === true || rawField["x-sensitive"] === true) return true;
299
+
300
+ const haystack = [
301
+ requestMessage,
302
+ fieldId.replace(/[_-]+/g, " "),
303
+ typeof field.title === "string" ? field.title : "",
304
+ typeof field.description === "string" ? field.description : "",
305
+ ]
306
+ .join(" ")
307
+ .toLowerCase();
308
+
309
+ return SENSITIVE_FIELD_PATTERN.test(haystack);
310
+ }
311
+
312
+ export function parseTextInputElicitation(
313
+ request: Pick<SdkElicitationRequest, "message" | "mode" | "requestedSchema">,
314
+ ): ParsedTextInputField[] | null {
315
+ if (request.mode && request.mode !== "form") return null;
316
+ const schema = request.requestedSchema as
317
+ | ({ properties?: Record<string, SdkElicitationFieldSchema>; keys?: Record<string, SdkElicitationFieldSchema> } & Record<string, unknown>)
318
+ | undefined;
319
+ const fieldsSource = schema?.properties && typeof schema.properties === "object"
320
+ ? schema.properties
321
+ : schema?.keys && typeof schema.keys === "object"
322
+ ? schema.keys
323
+ : undefined;
324
+ if (!fieldsSource) return null;
325
+
326
+ const requiredSet = new Set(
327
+ Array.isArray(request.requestedSchema?.required)
328
+ ? request.requestedSchema.required.filter((value): value is string => typeof value === "string")
329
+ : [],
330
+ );
331
+
332
+ const fields: ParsedTextInputField[] = [];
333
+ for (const [fieldId, field] of Object.entries(fieldsSource)) {
334
+ if (!field || typeof field !== "object") continue;
335
+ if (field.type !== "string") continue;
336
+ if (Array.isArray(field.oneOf) && field.oneOf.length > 0) continue;
337
+
338
+ fields.push({
339
+ id: fieldId,
340
+ title: typeof field.title === "string" && field.title.length > 0 ? field.title : fieldId,
341
+ description: typeof field.description === "string" ? field.description : "",
342
+ required: requiredSet.has(fieldId),
343
+ secure: isSecureElicitationField(request.message, fieldId, field),
344
+ });
345
+ }
346
+
347
+ return fields.length > 0 ? fields : null;
348
+ }
349
+
277
350
  export function roundResultToElicitationContent(
278
351
  questions: ParsedElicitationQuestion[],
279
352
  result: RoundResult,
@@ -355,6 +428,52 @@ async function promptElicitationWithDialogs(
355
428
  return { action: "accept", content };
356
429
  }
357
430
 
431
+ function buildTextInputPromptTitle(request: SdkElicitationRequest, field: ParsedTextInputField): string {
432
+ const parts = [
433
+ request.serverName ? `[${request.serverName}]` : "",
434
+ field.title,
435
+ field.description,
436
+ ].filter((part) => typeof part === "string" && part.trim().length > 0);
437
+ return parts.join("\n\n");
438
+ }
439
+
440
+ function buildTextInputPlaceholder(field: ParsedTextInputField): string | undefined {
441
+ const desc = field.description.trim();
442
+ if (!desc) return field.required ? "Required" : "Leave empty to skip";
443
+
444
+ const formatLine = desc
445
+ .split(/\r?\n/)
446
+ .map((line) => line.trim())
447
+ .find((line) => /^format:/i.test(line));
448
+
449
+ if (!formatLine) return field.required ? "Required" : "Leave empty to skip";
450
+ const hint = formatLine.replace(/^format:\s*/i, "").trim();
451
+ return hint.length > 0 ? hint : field.required ? "Required" : "Leave empty to skip";
452
+ }
453
+
454
+ async function promptTextInputElicitation(
455
+ request: SdkElicitationRequest,
456
+ fields: ParsedTextInputField[],
457
+ ui: ExtensionUIContext,
458
+ signal: AbortSignal,
459
+ ): Promise<SdkElicitationResult> {
460
+ const content: Record<string, string | string[]> = {};
461
+
462
+ for (const field of fields) {
463
+ const value = await ui.input(
464
+ buildTextInputPromptTitle(request, field),
465
+ buildTextInputPlaceholder(field),
466
+ { signal, ...(field.secure ? { secure: true } : {}) },
467
+ );
468
+ if (value === undefined) {
469
+ return { action: "cancel" };
470
+ }
471
+ content[field.id] = value;
472
+ }
473
+
474
+ return { action: "accept", content };
475
+ }
476
+
358
477
  export function createClaudeCodeElicitationHandler(
359
478
  ui: ExtensionUIContext | undefined,
360
479
  ): ((request: SdkElicitationRequest, options: { signal: AbortSignal }) => Promise<SdkElicitationResult>) | undefined {
@@ -366,19 +485,24 @@ export function createClaudeCodeElicitationHandler(
366
485
  }
367
486
 
368
487
  const questions = parseAskUserQuestionsElicitation(request);
369
- if (!questions) {
370
- return { action: "decline" };
488
+ if (questions) {
489
+ const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined);
490
+ if (interviewResult && Object.keys(interviewResult.answers).length > 0) {
491
+ return {
492
+ action: "accept",
493
+ content: roundResultToElicitationContent(questions, interviewResult),
494
+ };
495
+ }
496
+
497
+ return promptElicitationWithDialogs(request, questions, ui, signal);
371
498
  }
372
499
 
373
- const interviewResult = await showInterviewRound(questions, { signal }, { ui } as any).catch(() => undefined);
374
- if (interviewResult && Object.keys(interviewResult.answers).length > 0) {
375
- return {
376
- action: "accept",
377
- content: roundResultToElicitationContent(questions, interviewResult),
378
- };
500
+ const textFields = parseTextInputElicitation(request);
501
+ if (textFields) {
502
+ return promptTextInputElicitation(request, textFields, ui, signal);
379
503
  }
380
504
 
381
- return promptElicitationWithDialogs(request, questions, ui, signal);
505
+ return { action: "decline" };
382
506
  };
383
507
  }
384
508
 
@@ -508,15 +632,15 @@ export function extractToolResultsFromSdkUserMessage(message: SDKUserMessage): A
508
632
  return extracted;
509
633
  }
510
634
 
511
- function attachExternalResultsToToolCalls(
512
- toolCalls: AssistantMessage["content"],
635
+ function attachExternalResultsToToolBlocks(
636
+ toolBlocks: AssistantMessage["content"],
513
637
  toolResultsById: ReadonlyMap<string, ExternalToolResultPayload>,
514
638
  ): void {
515
- for (const block of toolCalls) {
516
- if (block.type !== "toolCall") continue;
639
+ for (const block of toolBlocks) {
640
+ if (block.type !== "toolCall" && block.type !== "serverToolUse") continue;
517
641
  const externalResult = toolResultsById.get(block.id);
518
642
  if (!externalResult) continue;
519
- (block as ToolCallWithExternalResult).externalResult = externalResult;
643
+ (block as ToolCallWithExternalResult & { id: string }).externalResult = externalResult;
520
644
  }
521
645
  }
522
646
 
@@ -554,8 +678,8 @@ async function pumpSdkMessages(
554
678
  /** Track the last text content seen across all assistant turns for the final message. */
555
679
  let lastTextContent = "";
556
680
  let lastThinkingContent = "";
557
- /** Collect tool calls from intermediate SDK turns for tool_execution events. */
558
- const intermediateToolCalls: AssistantMessage["content"] = [];
681
+ /** Collect tool blocks from intermediate SDK turns for tool execution rendering. */
682
+ const intermediateToolBlocks: AssistantMessage["content"] = [];
559
683
  /** Preserve real external tool results from Claude Code's synthetic user messages. */
560
684
  const toolResultsById = new Map<string, ExternalToolResultPayload>();
561
685
 
@@ -666,9 +790,9 @@ async function pumpSdkMessages(
666
790
  lastTextContent = block.text;
667
791
  } else if (block.type === "thinking" && block.thinking) {
668
792
  lastThinkingContent = block.thinking;
669
- } else if (block.type === "toolCall") {
670
- // Collect tool calls for externalToolExecution rendering
671
- intermediateToolCalls.push(block);
793
+ } else if (block.type === "toolCall" || block.type === "serverToolUse") {
794
+ // Collect tool blocks for externalToolExecution rendering
795
+ intermediateToolBlocks.push(block);
672
796
  }
673
797
  }
674
798
  }
@@ -678,24 +802,33 @@ async function pumpSdkMessages(
678
802
  for (const { toolUseId, result } of extractToolResultsFromSdkUserMessage(msg as SDKUserMessage)) {
679
803
  toolResultsById.set(toolUseId, result);
680
804
  }
681
- attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById);
805
+ attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById);
682
806
 
683
807
  // Push a synthetic toolcall_end for each tool call from this turn
684
808
  // so the TUI can render tool results in real-time during the SDK
685
809
  // session instead of waiting until the entire session completes.
686
810
  if (builder) {
687
811
  for (const block of builder.message.content) {
688
- if (block.type !== "toolCall") continue;
689
812
  const extResult = (block as ToolCallWithExternalResult).externalResult;
690
813
  if (!extResult) continue;
691
- // Push a toolcall_end with result attached so the chat-controller
692
- // can call updateResult on the pending ToolExecutionComponent.
693
- stream.push({
694
- type: "toolcall_end",
695
- contentIndex: builder.message.content.indexOf(block),
696
- toolCall: block,
697
- partial: builder.message,
698
- });
814
+ const contentIndex = builder.message.content.indexOf(block);
815
+ if (contentIndex < 0) continue;
816
+ // Push synthetic completion events with result attached so the
817
+ // chat-controller can update pending ToolExecutionComponents.
818
+ if (block.type === "toolCall") {
819
+ stream.push({
820
+ type: "toolcall_end",
821
+ contentIndex,
822
+ toolCall: block,
823
+ partial: builder.message,
824
+ });
825
+ } else if (block.type === "serverToolUse") {
826
+ stream.push({
827
+ type: "server_tool_use",
828
+ contentIndex,
829
+ partial: builder.message,
830
+ });
831
+ }
699
832
  }
700
833
  }
701
834
 
@@ -713,8 +846,8 @@ async function pumpSdkMessages(
713
846
  const finalContent: AssistantMessage["content"] = [];
714
847
 
715
848
  // Add tool calls from intermediate turns first (renders above text)
716
- attachExternalResultsToToolCalls(intermediateToolCalls, toolResultsById);
717
- finalContent.push(...intermediateToolCalls);
849
+ attachExternalResultsToToolBlocks(intermediateToolBlocks, toolResultsById);
850
+ finalContent.push(...intermediateToolBlocks);
718
851
 
719
852
  // Add text/thinking from the last turn
720
853
  if (builder && builder.message.content.length > 0) {
@@ -11,6 +11,7 @@ import {
11
11
  extractToolResultsFromSdkUserMessage,
12
12
  getClaudeLookupCommand,
13
13
  parseAskUserQuestionsElicitation,
14
+ parseTextInputElicitation,
14
15
  parseClaudeLookupOutput,
15
16
  roundResultToElicitationContent,
16
17
  } from "../stream-adapter.ts";
@@ -514,6 +515,117 @@ describe("stream-adapter — MCP elicitation bridge", () => {
514
515
  },
515
516
  });
516
517
  });
518
+
519
+ test("parseTextInputElicitation recognizes secure free-text MCP forms", () => {
520
+ const request = {
521
+ serverName: "gsd-workflow",
522
+ message: "Enter values for environment variables.",
523
+ mode: "form" as const,
524
+ requestedSchema: {
525
+ type: "object" as const,
526
+ properties: {
527
+ TEST_PASSWORD: {
528
+ type: "string",
529
+ title: "TEST_PASSWORD",
530
+ description: "Format: min 8 characters\nLeave empty to skip.",
531
+ },
532
+ PROJECT_NAME: {
533
+ type: "string",
534
+ title: "PROJECT_NAME",
535
+ description: "Human-readable project name.",
536
+ },
537
+ },
538
+ },
539
+ };
540
+
541
+ const parsed = parseTextInputElicitation(request as any);
542
+ assert.deepEqual(parsed, [
543
+ {
544
+ id: "TEST_PASSWORD",
545
+ title: "TEST_PASSWORD",
546
+ description: "Format: min 8 characters\nLeave empty to skip.",
547
+ required: false,
548
+ secure: true,
549
+ },
550
+ {
551
+ id: "PROJECT_NAME",
552
+ title: "PROJECT_NAME",
553
+ description: "Human-readable project name.",
554
+ required: false,
555
+ secure: false,
556
+ },
557
+ ]);
558
+ });
559
+
560
+ test("parseTextInputElicitation accepts legacy keys schema and skips unsupported fields", () => {
561
+ const request = {
562
+ serverName: "gsd-workflow",
563
+ message: "Enter secure values",
564
+ mode: "form" as const,
565
+ requestedSchema: {
566
+ type: "object" as const,
567
+ keys: {
568
+ API_TOKEN: {
569
+ type: "string",
570
+ title: "API_TOKEN",
571
+ description: "Leave empty to skip.",
572
+ },
573
+ META: {
574
+ type: "object",
575
+ title: "metadata",
576
+ },
577
+ },
578
+ },
579
+ };
580
+
581
+ const parsed = parseTextInputElicitation(request as any);
582
+ assert.deepEqual(parsed, [
583
+ {
584
+ id: "API_TOKEN",
585
+ title: "API_TOKEN",
586
+ description: "Leave empty to skip.",
587
+ required: false,
588
+ secure: true,
589
+ },
590
+ ]);
591
+ });
592
+
593
+ test("createClaudeCodeElicitationHandler collects secure_env_collect fields through input dialogs", async () => {
594
+ const secureRequest = {
595
+ serverName: "gsd-workflow",
596
+ message: "Enter values for environment variables.",
597
+ mode: "form" as const,
598
+ requestedSchema: {
599
+ type: "object" as const,
600
+ properties: {
601
+ TEST_PASSWORD: {
602
+ type: "string",
603
+ title: "TEST_PASSWORD",
604
+ description: "Format: Your secure testing password\nLeave empty to skip.",
605
+ },
606
+ },
607
+ },
608
+ };
609
+
610
+ const inputCalls: Array<{ opts?: { secure?: boolean } }> = [];
611
+ const handler = createClaudeCodeElicitationHandler({
612
+ input: async (_title: string, _placeholder?: string, opts?: { secure?: boolean }) => {
613
+ inputCalls.push({ opts });
614
+ return "super-secret";
615
+ },
616
+ } as any);
617
+ assert.ok(handler);
618
+
619
+ const result = await handler!(secureRequest as any, { signal: new AbortController().signal });
620
+ assert.deepEqual(result, {
621
+ action: "accept",
622
+ content: {
623
+ TEST_PASSWORD: "super-secret",
624
+ },
625
+ });
626
+ assert.equal(inputCalls.length, 1);
627
+ assert.equal(inputCalls[0]?.opts?.secure, true, "secure_env_collect fields should request secure input");
628
+ });
517
629
  });
518
630
 
519
631
  describe("stream-adapter — Windows Claude path lookup (#3770)", () => {
@@ -126,7 +126,7 @@ async function collectOneSecret(
126
126
  ): Promise<string | null> {
127
127
  if (!ctx.hasUI) return null;
128
128
 
129
- return ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
129
+ const customResult = await ctx.ui.custom((tui: any, theme: any, _kb: any, done: (r: string | null) => void) => {
130
130
  let value = "";
131
131
  let cachedLines: string[] | undefined;
132
132
 
@@ -223,6 +223,29 @@ async function collectOneSecret(
223
223
  handleInput,
224
224
  };
225
225
  });
226
+
227
+ // RPC/web surfaces may not implement ctx.ui.custom(). Fall back to a
228
+ // standard input prompt so users can still provide the secret.
229
+ if (customResult !== undefined) {
230
+ return customResult;
231
+ }
232
+
233
+ if (typeof ctx.ui?.input !== "function") {
234
+ return null;
235
+ }
236
+
237
+ const inputTitle = `Secure value for ${keyName} (${pageIndex + 1}/${totalPages})`;
238
+ const inputPlaceholder = hint || "Enter secret value";
239
+ const inputResult = await ctx.ui.input(
240
+ inputTitle,
241
+ inputPlaceholder,
242
+ { secure: true },
243
+ );
244
+ if (typeof inputResult !== "string") {
245
+ return null;
246
+ }
247
+ const trimmed = inputResult.trim();
248
+ return trimmed.length > 0 ? trimmed : null;
226
249
  }
227
250
 
228
251
  /**
@@ -34,6 +34,7 @@ import {
34
34
  import { injectContext } from "./context-injector.js";
35
35
  import type { WorkflowDefinition, StepDefinition } from "./definition-loader.js";
36
36
  import { parseUnitId } from "./unit-id.js";
37
+ import { withFileLock } from "./file-lock.js";
37
38
 
38
39
  /** Read and parse the frozen DEFINITION.yaml from a run directory. */
39
40
  export function readFrozenDefinition(runDir: string): WorkflowDefinition {
@@ -179,24 +180,28 @@ export class CustomWorkflowEngine implements WorkflowEngine {
179
180
  state: EngineState,
180
181
  completedStep: CompletedStep,
181
182
  ): Promise<ReconcileResult> {
182
- // Re-read the graph from disk so we do not overwrite concurrent
183
- // workflow edits with a stale in-memory snapshot from deriveState().
184
- const graph = readGraph(this.runDir);
183
+ const graphPath = join(this.runDir, "GRAPH.yaml");
185
184
 
186
- // Extract stepId from "<workflowName>/<stepId>"
187
- const { milestone, slice, task } = parseUnitId(completedStep.unitId);
188
- const stepId = task ?? slice ?? milestone;
185
+ return await withFileLock(graphPath, () => {
186
+ // Re-read the graph from disk so we do not overwrite concurrent
187
+ // workflow edits with a stale in-memory snapshot from deriveState().
188
+ const graph = readGraph(this.runDir);
189
189
 
190
- const updatedGraph = markStepComplete(graph, stepId);
191
- writeGraph(this.runDir, updatedGraph);
190
+ // Extract stepId from "<workflowName>/<stepId>"
191
+ const { milestone, slice, task } = parseUnitId(completedStep.unitId);
192
+ const stepId = task ?? slice ?? milestone;
192
193
 
193
- const allDone = updatedGraph.steps.every(
194
- (s) => s.status === "complete" || s.status === "expanded",
195
- );
194
+ const updatedGraph = markStepComplete(graph, stepId);
195
+ writeGraph(this.runDir, updatedGraph);
196
196
 
197
- return {
198
- outcome: allDone ? "milestone-complete" : "continue",
199
- };
197
+ const allDone = updatedGraph.steps.every(
198
+ (s) => s.status === "complete" || s.status === "expanded",
199
+ );
200
+
201
+ return {
202
+ outcome: allDone ? "milestone-complete" : "continue",
203
+ };
204
+ });
200
205
  }
201
206
 
202
207
  /**
@@ -0,0 +1,59 @@
1
+ import { existsSync } from "node:fs";
2
+
3
+ function _require(name: string) {
4
+ try {
5
+ return require(name);
6
+ } catch {
7
+ try {
8
+ const gsdPiRequire = require("module").createRequire(
9
+ require("path").join(process.cwd(), "node_modules", "gsd-pi", "index.js")
10
+ );
11
+ return gsdPiRequire(name);
12
+ } catch {
13
+ return null;
14
+ }
15
+ }
16
+ }
17
+
18
+ export function withFileLockSync<T>(filePath: string, fn: () => T): T {
19
+ const lockfile = _require("proper-lockfile");
20
+ if (!lockfile) return fn();
21
+
22
+ if (!existsSync(filePath)) return fn();
23
+
24
+ try {
25
+ const release = lockfile.lockSync(filePath, { retries: 5, stale: 10000 });
26
+ try {
27
+ return fn();
28
+ } finally {
29
+ release();
30
+ }
31
+ } catch (err: any) {
32
+ if (err.code === "ELOCKED") {
33
+ // Could not get lock after retries, let's fallback to un-locked instead of crashing the whole state machine
34
+ return fn();
35
+ }
36
+ throw err;
37
+ }
38
+ }
39
+
40
+ export async function withFileLock<T>(filePath: string, fn: () => Promise<T> | T): Promise<T> {
41
+ const lockfile = _require("proper-lockfile");
42
+ if (!lockfile) return await fn();
43
+
44
+ if (!existsSync(filePath)) return await fn();
45
+
46
+ try {
47
+ const release = await lockfile.lock(filePath, { retries: 5, stale: 10000 });
48
+ try {
49
+ return await fn();
50
+ } finally {
51
+ await release();
52
+ }
53
+ } catch (err: any) {
54
+ if (err.code === "ELOCKED") {
55
+ return await fn();
56
+ }
57
+ throw err;
58
+ }
59
+ }