oh-my-codex 0.17.3 → 0.18.0

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 (158) hide show
  1. package/Cargo.lock +13 -5
  2. package/Cargo.toml +2 -1
  3. package/README.md +1 -0
  4. package/crates/omx-api/Cargo.toml +19 -0
  5. package/crates/omx-api/src/lib.rs +2940 -0
  6. package/crates/omx-api/src/main.rs +10 -0
  7. package/crates/omx-api/tests/cli.rs +558 -0
  8. package/crates/omx-explore/src/main.rs +4 -0
  9. package/crates/omx-sparkshell/src/codex_bridge.rs +437 -123
  10. package/crates/omx-sparkshell/src/exec.rs +4 -0
  11. package/crates/omx-sparkshell/src/main.rs +738 -29
  12. package/crates/omx-sparkshell/src/prompt.rs +25 -3
  13. package/crates/omx-sparkshell/src/redaction.rs +241 -0
  14. package/crates/omx-sparkshell/tests/execution.rs +479 -238
  15. package/dist/cli/__tests__/api.test.d.ts +2 -0
  16. package/dist/cli/__tests__/api.test.d.ts.map +1 -0
  17. package/dist/cli/__tests__/api.test.js +175 -0
  18. package/dist/cli/__tests__/api.test.js.map +1 -0
  19. package/dist/cli/__tests__/ask.test.js +72 -5
  20. package/dist/cli/__tests__/ask.test.js.map +1 -1
  21. package/dist/cli/__tests__/autoresearch-goal.test.js +14 -1
  22. package/dist/cli/__tests__/autoresearch-goal.test.js.map +1 -1
  23. package/dist/cli/__tests__/explore.test.js +23 -0
  24. package/dist/cli/__tests__/explore.test.js.map +1 -1
  25. package/dist/cli/__tests__/index.test.js +123 -5
  26. package/dist/cli/__tests__/index.test.js.map +1 -1
  27. package/dist/cli/__tests__/launch-fallback.test.js +76 -0
  28. package/dist/cli/__tests__/launch-fallback.test.js.map +1 -1
  29. package/dist/cli/__tests__/package-bin-contract.test.js +4 -3
  30. package/dist/cli/__tests__/package-bin-contract.test.js.map +1 -1
  31. package/dist/cli/__tests__/setup-install-mode.test.js +138 -0
  32. package/dist/cli/__tests__/setup-install-mode.test.js.map +1 -1
  33. package/dist/cli/__tests__/sparkshell-cli.test.js +5 -0
  34. package/dist/cli/__tests__/sparkshell-cli.test.js.map +1 -1
  35. package/dist/cli/__tests__/version-sync-contract.test.js +4 -0
  36. package/dist/cli/__tests__/version-sync-contract.test.js.map +1 -1
  37. package/dist/cli/__tests__/windows-popup-loop-contract.test.js +1 -1
  38. package/dist/cli/__tests__/windows-popup-loop-contract.test.js.map +1 -1
  39. package/dist/cli/api.d.ts +26 -0
  40. package/dist/cli/api.d.ts.map +1 -0
  41. package/dist/cli/api.js +153 -0
  42. package/dist/cli/api.js.map +1 -0
  43. package/dist/cli/explore.d.ts +2 -0
  44. package/dist/cli/explore.d.ts.map +1 -1
  45. package/dist/cli/explore.js +43 -1
  46. package/dist/cli/explore.js.map +1 -1
  47. package/dist/cli/index.d.ts +10 -4
  48. package/dist/cli/index.d.ts.map +1 -1
  49. package/dist/cli/index.js +128 -10
  50. package/dist/cli/index.js.map +1 -1
  51. package/dist/cli/native-assets.d.ts +2 -1
  52. package/dist/cli/native-assets.d.ts.map +1 -1
  53. package/dist/cli/native-assets.js +1 -0
  54. package/dist/cli/native-assets.js.map +1 -1
  55. package/dist/cli/sparkshell.d.ts.map +1 -1
  56. package/dist/cli/sparkshell.js +20 -3
  57. package/dist/cli/sparkshell.js.map +1 -1
  58. package/dist/config/generator.d.ts.map +1 -1
  59. package/dist/config/generator.js +90 -0
  60. package/dist/config/generator.js.map +1 -1
  61. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts +2 -0
  62. package/dist/hooks/__tests__/best-practice-research-skill.test.d.ts.map +1 -0
  63. package/dist/hooks/__tests__/best-practice-research-skill.test.js +27 -0
  64. package/dist/hooks/__tests__/best-practice-research-skill.test.js.map +1 -0
  65. package/dist/hooks/__tests__/keyword-detector.test.js +11 -0
  66. package/dist/hooks/__tests__/keyword-detector.test.js.map +1 -1
  67. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js +6 -0
  68. package/dist/hooks/__tests__/notify-hook-team-leader-nudge.test.js.map +1 -1
  69. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js +4 -0
  70. package/dist/hooks/__tests__/prompt-guidance-wave-two.test.js.map +1 -1
  71. package/dist/hooks/keyword-registry.d.ts.map +1 -1
  72. package/dist/hooks/keyword-registry.js +1 -0
  73. package/dist/hooks/keyword-registry.js.map +1 -1
  74. package/dist/hud/__tests__/reconcile.test.js +2 -2
  75. package/dist/hud/__tests__/reconcile.test.js.map +1 -1
  76. package/dist/hud/__tests__/tmux.test.js +23 -18
  77. package/dist/hud/__tests__/tmux.test.js.map +1 -1
  78. package/dist/hud/tmux.d.ts.map +1 -1
  79. package/dist/hud/tmux.js +7 -6
  80. package/dist/hud/tmux.js.map +1 -1
  81. package/dist/mcp/__tests__/bootstrap.test.js +75 -1
  82. package/dist/mcp/__tests__/bootstrap.test.js.map +1 -1
  83. package/dist/mcp/bootstrap.d.ts +3 -1
  84. package/dist/mcp/bootstrap.d.ts.map +1 -1
  85. package/dist/mcp/bootstrap.js +71 -2
  86. package/dist/mcp/bootstrap.js.map +1 -1
  87. package/dist/scripts/__tests__/codex-native-hook.test.js +737 -26
  88. package/dist/scripts/__tests__/codex-native-hook.test.js.map +1 -1
  89. package/dist/scripts/__tests__/notify-dispatcher.test.js +183 -1
  90. package/dist/scripts/__tests__/notify-dispatcher.test.js.map +1 -1
  91. package/dist/scripts/__tests__/smoke-packed-install.test.js +4 -1
  92. package/dist/scripts/__tests__/smoke-packed-install.test.js.map +1 -1
  93. package/dist/scripts/build-api.d.ts +2 -0
  94. package/dist/scripts/build-api.d.ts.map +1 -0
  95. package/dist/scripts/build-api.js +44 -0
  96. package/dist/scripts/build-api.js.map +1 -0
  97. package/dist/scripts/codex-native-hook.d.ts.map +1 -1
  98. package/dist/scripts/codex-native-hook.js +208 -8
  99. package/dist/scripts/codex-native-hook.js.map +1 -1
  100. package/dist/scripts/codex-native-pre-post.d.ts.map +1 -1
  101. package/dist/scripts/codex-native-pre-post.js +89 -24
  102. package/dist/scripts/codex-native-pre-post.js.map +1 -1
  103. package/dist/scripts/notify-dispatcher.js +88 -0
  104. package/dist/scripts/notify-dispatcher.js.map +1 -1
  105. package/dist/scripts/notify-hook/team-dispatch.d.ts.map +1 -1
  106. package/dist/scripts/notify-hook/team-dispatch.js +27 -9
  107. package/dist/scripts/notify-hook/team-dispatch.js.map +1 -1
  108. package/dist/scripts/notify-hook/team-leader-nudge.d.ts.map +1 -1
  109. package/dist/scripts/notify-hook/team-leader-nudge.js +26 -11
  110. package/dist/scripts/notify-hook/team-leader-nudge.js.map +1 -1
  111. package/dist/scripts/notify-hook/team-tmux-guard.d.ts +1 -0
  112. package/dist/scripts/notify-hook/team-tmux-guard.d.ts.map +1 -1
  113. package/dist/scripts/notify-hook/team-tmux-guard.js +38 -0
  114. package/dist/scripts/notify-hook/team-tmux-guard.js.map +1 -1
  115. package/dist/scripts/notify-hook/team-worker-stop.d.ts.map +1 -1
  116. package/dist/scripts/notify-hook/team-worker-stop.js +27 -14
  117. package/dist/scripts/notify-hook/team-worker-stop.js.map +1 -1
  118. package/dist/scripts/run-provider-advisor.js +9 -3
  119. package/dist/scripts/run-provider-advisor.js.map +1 -1
  120. package/dist/scripts/smoke-packed-install.d.ts +1 -1
  121. package/dist/scripts/smoke-packed-install.d.ts.map +1 -1
  122. package/dist/scripts/smoke-packed-install.js +2 -0
  123. package/dist/scripts/smoke-packed-install.js.map +1 -1
  124. package/dist/team/__tests__/runtime.test.js +2 -2
  125. package/dist/team/__tests__/runtime.test.js.map +1 -1
  126. package/dist/team/__tests__/tmux-session.test.js +96 -19
  127. package/dist/team/__tests__/tmux-session.test.js.map +1 -1
  128. package/dist/team/tmux-session.d.ts +1 -0
  129. package/dist/team/tmux-session.d.ts.map +1 -1
  130. package/dist/team/tmux-session.js +34 -10
  131. package/dist/team/tmux-session.js.map +1 -1
  132. package/dist/verification/__tests__/ci-rust-gates.test.js +85 -10
  133. package/dist/verification/__tests__/ci-rust-gates.test.js.map +1 -1
  134. package/dist/verification/__tests__/explore-harness-release-workflow.test.js +1 -0
  135. package/dist/verification/__tests__/explore-harness-release-workflow.test.js.map +1 -1
  136. package/package.json +4 -3
  137. package/plugins/oh-my-codex/.codex-plugin/plugin.json +1 -1
  138. package/plugins/oh-my-codex/skills/best-practice-research/SKILL.md +83 -0
  139. package/plugins/oh-my-codex/skills/deep-interview/SKILL.md +1 -0
  140. package/plugins/oh-my-codex/skills/ralplan/SKILL.md +1 -1
  141. package/prompts/researcher.md +15 -10
  142. package/skills/best-practice-research/SKILL.md +83 -0
  143. package/skills/deep-interview/SKILL.md +1 -0
  144. package/skills/ralplan/SKILL.md +1 -1
  145. package/src/scripts/__tests__/codex-native-hook.test.ts +810 -4
  146. package/src/scripts/__tests__/notify-dispatcher.test.ts +223 -1
  147. package/src/scripts/__tests__/smoke-packed-install.test.ts +8 -2
  148. package/src/scripts/build-api.ts +48 -0
  149. package/src/scripts/codex-native-hook.ts +262 -10
  150. package/src/scripts/codex-native-pre-post.ts +103 -24
  151. package/src/scripts/notify-dispatcher.ts +97 -0
  152. package/src/scripts/notify-hook/team-dispatch.ts +27 -8
  153. package/src/scripts/notify-hook/team-leader-nudge.ts +25 -11
  154. package/src/scripts/notify-hook/team-tmux-guard.ts +42 -0
  155. package/src/scripts/notify-hook/team-worker-stop.ts +24 -13
  156. package/src/scripts/run-provider-advisor.ts +11 -3
  157. package/src/scripts/smoke-packed-install.ts +2 -0
  158. package/templates/catalog-manifest.json +7 -0
@@ -103,8 +103,8 @@ export function normalizePostToolUsePayload(
103
103
  const exitCode = safeInteger(parsedToolResponse?.exit_code)
104
104
  ?? safeInteger(parsedToolResponse?.exitCode)
105
105
  ?? null;
106
- const rawText = safeString(rawToolResponse).trim();
107
- const stdoutText = safeString(parsedToolResponse?.stdout).trim() || rawText;
106
+ const rawToolResponseText = safeString(rawToolResponse).trim();
107
+ const stdoutText = safeString(parsedToolResponse?.stdout).trim() || rawToolResponseText;
108
108
  const stderrText = safeString(parsedToolResponse?.stderr).trim();
109
109
 
110
110
  return {
@@ -149,30 +149,49 @@ type OmxParityCommand =
149
149
  | "trace"
150
150
  | "code-intel";
151
151
 
152
+ function joinNonEmptyText(parts: string[]): string {
153
+ return parts
154
+ .filter(Boolean)
155
+ .join("\n")
156
+ .trim();
157
+ }
158
+
159
+ function structuredMcpTransportText(normalized: NormalizedPostToolUsePayload): string {
160
+ return joinNonEmptyText([
161
+ safeString(normalized.parsedToolResponse?.error),
162
+ safeString(normalized.parsedToolResponse?.message),
163
+ safeString(normalized.parsedToolResponse?.details),
164
+ ]);
165
+ }
166
+
167
+ function hasMcpTransportContext(text: string): boolean {
168
+ return /\bmcp\b/i.test(text)
169
+ || /\bomx-(?:state|memory|trace|code-intel)-server\b/i.test(text);
170
+ }
171
+
172
+ function hasMcpTransportFailurePattern(text: string): boolean {
173
+ return MCP_TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(text));
174
+ }
175
+
152
176
  export function detectMcpTransportFailure(
153
177
  payload: CodexHookPayload,
154
178
  ): McpTransportFailureSignal | null {
155
179
  const normalized = normalizePostToolUsePayload(payload);
156
180
  if (normalized.isBash) return null;
157
- const combined = [
181
+
182
+ const isMcpTool = isMcpLikeToolName(normalized.toolName);
183
+ const structuredText = structuredMcpTransportText(normalized);
184
+ const rawText = joinNonEmptyText([
158
185
  normalized.stderrText,
159
186
  normalized.stdoutText,
160
- safeString(normalized.parsedToolResponse?.error),
161
- safeString(normalized.parsedToolResponse?.message),
162
- safeString(normalized.parsedToolResponse?.details),
163
- ]
164
- .filter(Boolean)
165
- .join("\n")
166
- .trim();
187
+ ]);
188
+ const combined = isMcpTool
189
+ ? joinNonEmptyText([rawText, structuredText])
190
+ : structuredText;
167
191
 
168
- const mcpContextDetected = isMcpLikeToolName(normalized.toolName)
169
- || /\bmcp\b/i.test(combined)
170
- || /\bomx-(?:state|memory|trace|code-intel)-server\b/i.test(combined);
171
- if (!mcpContextDetected) return null;
172
192
  if (!combined) return null;
173
- if (!MCP_TRANSPORT_FAILURE_PATTERNS.some((pattern) => pattern.test(combined))) {
174
- return null;
175
- }
193
+ if (!isMcpTool && !hasMcpTransportContext(structuredText)) return null;
194
+ if (!hasMcpTransportFailurePattern(combined)) return null;
176
195
 
177
196
  return {
178
197
  toolName: normalized.toolName,
@@ -976,15 +995,75 @@ function buildSloppyFallbackPreToolUseOutput(commandText: string): Record<string
976
995
  };
977
996
  }
978
997
 
998
+ function removeHereDocBodies(command: string): string {
999
+ const lines = command.split(/\r?\n/);
1000
+ const retained: string[] = [];
1001
+ let pendingDelimiter: string | null = null;
1002
+
1003
+ for (const line of lines) {
1004
+ if (pendingDelimiter) {
1005
+ if (line.trim() === pendingDelimiter) {
1006
+ pendingDelimiter = null;
1007
+ }
1008
+ continue;
1009
+ }
1010
+
1011
+ retained.push(line);
1012
+ const match = /<<-?\s*(?:"([^"]+)"|'([^']+)'|([A-Za-z0-9_.-]+))/.exec(line);
1013
+ if (match) pendingDelimiter = match[1] || match[2] || match[3] || null;
1014
+ }
1015
+
1016
+ return retained.join("\n");
1017
+ }
1018
+
979
1019
  function commandInvokesOmxQuestion(command: string): boolean {
980
- const tokens = tokenizeShellCommand(command)?.map((token) => token.toLowerCase()) ?? [];
981
- for (let index = 0; index < tokens.length; index += 1) {
982
- const rawToken = tokens[index] || '';
983
- const token = rawToken.replace(/\\/g, '/').split('/').pop() || '';
984
- if ((token === 'omx' || token === 'omx.js') && tokens[index + 1] === 'question') return true;
985
- if ((token === 'node' || token === 'node.exe') && /(?:^|\/)omx\.js$/.test(tokens[index + 1] || '') && tokens[index + 2] === 'question') return true;
1020
+ const tokens = tokenizeShellCommandWithBoundaries(removeHereDocBodies(command))
1021
+ ?.map((token) => ({ ...token, value: token.value.toLowerCase() }))
1022
+ ?? [];
1023
+
1024
+ for (let commandStart = 0; commandStart < tokens.length; commandStart = nextCommandStart(tokens, commandStart)) {
1025
+ const commandEnd = nextCommandStart(tokens, commandStart);
1026
+ let index = commandStart;
1027
+
1028
+ while (index < commandEnd && isInlineShellEnvAssignment(tokens[index]?.value ?? "")) {
1029
+ index += 1;
1030
+ }
1031
+
1032
+ while (index < commandEnd && isEnvExecutableToken(tokens[index]?.value ?? "")) {
1033
+ index += 1;
1034
+ while (index < commandEnd) {
1035
+ const token = tokens[index]?.value ?? "";
1036
+ if (token === "--") {
1037
+ index += 1;
1038
+ break;
1039
+ }
1040
+ if (isInlineShellEnvAssignment(token)) {
1041
+ index += 1;
1042
+ continue;
1043
+ }
1044
+ if (token === "-i" || token === "--ignore-environment" || token.startsWith("--unset=")) {
1045
+ index += 1;
1046
+ continue;
1047
+ }
1048
+ if (token.startsWith("-")) {
1049
+ index += envOptionConsumesNextValue(token) ? 2 : 1;
1050
+ continue;
1051
+ }
1052
+ break;
1053
+ }
1054
+ }
1055
+
1056
+ const rawToken = tokens[index]?.value || "";
1057
+ const token = rawToken.replace(/\\/g, "/").split("/").pop() || "";
1058
+ if ((token === "omx" || token === "omx.js") && tokens[index + 1]?.value === "question") return true;
1059
+ if (
1060
+ (token === "node" || token === "node.exe")
1061
+ && /(?:^|\/)omx\.js$/.test(tokens[index + 1]?.value || "")
1062
+ && tokens[index + 2]?.value === "question"
1063
+ ) return true;
986
1064
  }
987
- return /\bomx\s+question\b/i.test(command) || /\bomx\.js['"]?\s+question\b/i.test(command);
1065
+
1066
+ return false;
988
1067
  }
989
1068
 
990
1069
  function isQuestionReturnPaneAssignment(token: string): boolean {
@@ -52,6 +52,21 @@ function resolveNotifyEntrypoint(command: readonly string[]): string | undefined
52
52
  return command.slice(1).find((arg) => !arg.startsWith("-"));
53
53
  }
54
54
 
55
+ function getPreviousNotifyWrapperValue(
56
+ command: readonly string[],
57
+ ): string | undefined {
58
+ for (let index = 0; index < command.length; index += 1) {
59
+ const part = command[index];
60
+ if (part === "--previous-notify") {
61
+ return command[index + 1];
62
+ }
63
+ if (part.startsWith("--previous-notify=")) {
64
+ return part.slice("--previous-notify=".length);
65
+ }
66
+ }
67
+ return undefined;
68
+ }
69
+
55
70
  function isOmxManagedNotifyCommand(command: readonly string[] | null | undefined): boolean {
56
71
  if (!command) return false;
57
72
  const entrypoint = resolveNotifyEntrypoint(command);
@@ -62,12 +77,94 @@ function isOmxManagedNotifyCommand(command: readonly string[] | null | undefined
62
77
  return /(?:^|[\\/])oh-my-codex(?:[\\/]|$)/.test(entrypoint);
63
78
  }
64
79
 
80
+ function isOmxDispatcherMetadataCommand(command: readonly string[] | null | undefined): boolean {
81
+ if (!command) return false;
82
+ const entrypoint = resolveNotifyEntrypoint(command);
83
+ if (!entrypoint || !/(?:^|[\\/])notify-dispatcher\.js$/.test(entrypoint)) {
84
+ return false;
85
+ }
86
+ const metadataIndex = command.indexOf("--metadata");
87
+ const metadataPath = metadataIndex >= 0 ? command[metadataIndex + 1] : undefined;
88
+ return typeof metadataPath === "string" && /(?:^|[\\/])(?:\.omx[\\/])?notify-dispatch\.json$/.test(metadataPath);
89
+ }
90
+
91
+ function isOmxManagedPayloadText(value: string): boolean {
92
+ const containsManagedPackageNotify =
93
+ /(?:^|[\\/])notify-(?:hook|dispatcher)\.js(?:\s|$|["'])/.test(
94
+ value,
95
+ ) && /(?:^|[\\/])oh-my-codex(?:[\\/]|$)/.test(value);
96
+ const containsDispatcherMetadataNotify =
97
+ /(?:^|[\\/])notify-dispatcher\.js(?:\s|$|["'])/.test(value) &&
98
+ /--metadata(?:\s|=)/.test(value) &&
99
+ /(?:^|[\\/])(?:\.omx[\\/])?notify-dispatch\.json(?:\s|$|["'])/.test(value);
100
+ return containsManagedPackageNotify || containsDispatcherMetadataNotify;
101
+ }
102
+
103
+ function parseJsonString(value: string): unknown | undefined {
104
+ const trimmed = value.trim();
105
+ if (!trimmed) return undefined;
106
+ const first = trimmed[0];
107
+ if (first !== "[" && first !== "{" && first !== '"') return undefined;
108
+ try {
109
+ return JSON.parse(trimmed) as unknown;
110
+ } catch {
111
+ return undefined;
112
+ }
113
+ }
114
+
115
+ function containsOmxManagedNotifyPayload(value: unknown, depth = 0): boolean {
116
+ if (depth > 8 || value == null) return false;
117
+ if (typeof value === "string") {
118
+ const parsed = parseJsonString(value);
119
+ if (parsed !== undefined && parsed !== value) {
120
+ return containsOmxManagedNotifyPayload(parsed, depth + 1);
121
+ }
122
+ return isOmxManagedPayloadText(value);
123
+ }
124
+ if (Array.isArray(value)) {
125
+ if (value.every((item) => typeof item === "string")) {
126
+ const command = value as string[];
127
+ return (
128
+ isOmxManagedNotifyCommand(command) ||
129
+ isOmxDispatcherMetadataCommand(command) ||
130
+ isOmxManagedPreviousNotifyWrapper(command)
131
+ );
132
+ }
133
+ return value.some((item) => containsOmxManagedNotifyPayload(item, depth + 1));
134
+ }
135
+ if (typeof value === "object") {
136
+ const record = value as Record<string, unknown>;
137
+ return [
138
+ record.previousNotify,
139
+ record.previous_notify,
140
+ record.notify,
141
+ record.command,
142
+ record.argv,
143
+ record.args,
144
+ ].some((item) => containsOmxManagedNotifyPayload(item, depth + 1));
145
+ }
146
+ return false;
147
+ }
148
+
149
+ function isOmxManagedPreviousNotifyWrapper(
150
+ command: readonly string[] | null | undefined,
151
+ ): boolean {
152
+ if (!command) return false;
153
+ if (!command.some((part) => part === "turn-ended")) return false;
154
+ const previousNotify = getPreviousNotifyWrapperValue(command);
155
+ if (!previousNotify) return false;
156
+
157
+ return containsOmxManagedNotifyPayload(previousNotify);
158
+ }
159
+
65
160
  function isManagedPreviousNotify(
66
161
  previousNotify: readonly string[] | null | undefined,
67
162
  metadata: NotifyDispatcherMetadata | null,
68
163
  ): boolean {
69
164
  return (
70
165
  isOmxManagedNotifyCommand(previousNotify) ||
166
+ isOmxDispatcherMetadataCommand(previousNotify) ||
167
+ isOmxManagedPreviousNotifyWrapper(previousNotify) ||
71
168
  sameCommand(previousNotify, metadata?.omxNotify) ||
72
169
  sameCommand(previousNotify, metadata?.dispatcherNotify)
73
170
  );
@@ -726,12 +726,14 @@ function resolveWorkerCliForRequest(request, config) {
726
726
 
727
727
  function capturedPaneContainsTrigger(captured, trigger) {
728
728
  if (!captured || !trigger) return false;
729
- return normalizeTmuxCapture(captured).includes(normalizeTmuxCapture(trigger));
729
+ const normalizeForDraftMatch = (value) => normalizeTmuxCapture(value).replace(/-\s+/g, '-');
730
+ return normalizeForDraftMatch(captured).includes(normalizeForDraftMatch(trigger));
730
731
  }
731
732
 
732
733
  function capturedPaneContainsTriggerNearTail(captured, trigger, nonEmptyTailLines = 24) {
733
734
  if (!captured || !trigger) return false;
734
- const normalizedTrigger = normalizeTmuxCapture(trigger);
735
+ const normalizeForDraftMatch = (value) => normalizeTmuxCapture(value).replace(/-\s+/g, '-');
736
+ const normalizedTrigger = normalizeForDraftMatch(trigger);
735
737
  if (!normalizedTrigger) return false;
736
738
  const lines = safeString(captured)
737
739
  .split('\n')
@@ -739,7 +741,13 @@ function capturedPaneContainsTriggerNearTail(captured, trigger, nonEmptyTailLine
739
741
  .filter((line) => line.length > 0);
740
742
  if (lines.length === 0) return false;
741
743
  const tail = lines.slice(-Math.max(1, nonEmptyTailLines)).join(' ');
742
- return normalizeTmuxCapture(tail).includes(normalizedTrigger);
744
+ return normalizeForDraftMatch(tail).includes(normalizedTrigger);
745
+ }
746
+
747
+ function buildJoinedCapturePaneArgv(paneTarget, tailLines = 80) {
748
+ // Join wrapped visual lines so long path-like trigger text split by tmux
749
+ // remains comparable with the original trigger.
750
+ return ['capture-pane', '-J', '-t', paneTarget, '-p', '-S', `-${tailLines}`];
743
751
  }
744
752
 
745
753
  const INJECT_VERIFY_DELAY_MS = 250;
@@ -791,7 +799,7 @@ async function injectDispatchRequest(request, config, cwd, stateDir) {
791
799
  if (attemptCountAtStart >= 1) {
792
800
  try {
793
801
  // Narrow capture (8 lines) to scope check to input area, not scrollback output
794
- const preCapture = await runProcess('tmux', buildCapturePaneArgv(resolution.paneTarget, 8), 2000);
802
+ const preCapture = await runProcess('tmux', buildJoinedCapturePaneArgv(resolution.paneTarget, 8), 2000);
795
803
  preCaptureHasTrigger = capturedPaneContainsTrigger(preCapture.stdout, request.trigger_message);
796
804
  } catch {
797
805
  preCaptureHasTrigger = false;
@@ -830,8 +838,8 @@ async function injectDispatchRequest(request, config, cwd, stateDir) {
830
838
  // Post-injection verification: confirm the trigger text was consumed.
831
839
  // Fixes #391: without this, dispatch marks 'notified' even when the worker
832
840
  // pane is sitting on an unsent draft (C-m was not effectively applied).
833
- const verifyNarrowArgv = buildCapturePaneArgv(resolution.paneTarget, 8);
834
- const verifyWideArgv = buildCapturePaneArgv(resolution.paneTarget);
841
+ const verifyNarrowArgv = buildJoinedCapturePaneArgv(resolution.paneTarget, 8);
842
+ const verifyWideArgv = buildJoinedCapturePaneArgv(resolution.paneTarget);
835
843
  for (let round = 0; round < INJECT_VERIFY_ROUNDS; round++) {
836
844
  await new Promise((r) => setTimeout(r, INJECT_VERIFY_DELAY_MS));
837
845
  try {
@@ -842,6 +850,19 @@ async function injectDispatchRequest(request, config, cwd, stateDir) {
842
850
  // full-scrollback false positives.
843
851
  const narrowCap = await runProcess('tmux', verifyNarrowArgv, 2000);
844
852
  const wideCap = await runProcess('tmux', verifyWideArgv, 2000);
853
+ const triggerInNarrow = capturedPaneContainsTrigger(narrowCap.stdout, request.trigger_message);
854
+ const triggerNearTail = capturedPaneContainsTriggerNearTail(wideCap.stdout, request.trigger_message);
855
+ if (triggerInNarrow || triggerNearTail) {
856
+ // Draft is still visible, so C-m has not actually submitted it yet.
857
+ // Do not let transient spinner/active-task text mask an unsent draft.
858
+ await sendPaneInput({
859
+ paneTarget: resolution.paneTarget,
860
+ prompt: request.trigger_message,
861
+ submitKeyPresses,
862
+ typePrompt: false,
863
+ }).catch(() => {});
864
+ continue;
865
+ }
845
866
  // Worker is actively processing (mirrors sync path tmux-session.ts:1292-1294)
846
867
  if (paneHasActiveTask(wideCap.stdout)) {
847
868
  runtimeExec({ command: 'MarkDelivered', request_id: request.request_id }, stateDir, request.team_name);
@@ -862,8 +883,6 @@ async function injectDispatchRequest(request, config, cwd, stateDir) {
862
883
  if (request.to_worker !== 'leader-fixed' && !paneLooksReady(wideCap.stdout)) {
863
884
  continue;
864
885
  }
865
- const triggerInNarrow = capturedPaneContainsTrigger(narrowCap.stdout, request.trigger_message);
866
- const triggerNearTail = capturedPaneContainsTriggerNearTail(wideCap.stdout, request.trigger_message);
867
886
  if (!triggerInNarrow && !triggerNearTail) {
868
887
  runtimeExec({ command: 'MarkDelivered', request_id: request.request_id }, stateDir, request.team_name);
869
888
  return {
@@ -11,14 +11,14 @@ import { asNumber, safeString, isTerminalPhase } from './utils.js';
11
11
  import { readJsonIfExists, getScopedStateDirsForCurrentSession } from './state-io.js';
12
12
  import { runProcess } from './process-runner.js';
13
13
  import { logTmuxHookEvent } from './log.js';
14
- import { evaluatePaneInjectionReadiness, sendPaneInput } from './team-tmux-guard.js';
14
+ import { evaluatePaneInjectionReadiness, queuePaneInput, sendPaneInput } from './team-tmux-guard.js';
15
15
  import { resolvePaneTarget } from './tmux-injection.js';
16
16
  import { listNotifyCanonicalActiveTeams } from './active-team.js';
17
17
  import {
18
18
  classifyLeaderActionState,
19
19
  resolveLeaderNudgeIntent,
20
20
  } from './orchestration-intent.js';
21
- import { DEFAULT_MARKER } from '../tmux-hook-engine.js';
21
+ import { DEFAULT_MARKER, paneHasActiveTask } from '../tmux-hook-engine.js';
22
22
  import { isLeaderRuntimeStale } from '../../team/leader-activity.js';
23
23
  import { appendTeamDeliveryLog } from '../../team/delivery-log.js';
24
24
  import { writeTeamLeaderAttention } from '../../team/state.js';
@@ -967,14 +967,27 @@ export async function maybeNudgeTeamLeader({
967
967
  }
968
968
 
969
969
  try {
970
- const sendResult = await sendPaneInput({
971
- paneTarget: tmuxTarget,
972
- prompt: markedText,
973
- submitKeyPresses: 2,
974
- submitDelayMs: 100,
975
- });
976
- if (!sendResult.ok) {
977
- throw new Error(sendResult.error || sendResult.reason);
970
+ const leaderHasActiveTask = paneHasActiveTask(paneGuard.paneCapture);
971
+ let deliveryMode = 'sent';
972
+ if (leaderHasActiveTask) {
973
+ const sendResult = await queuePaneInput({
974
+ paneTarget: tmuxTarget,
975
+ prompt: markedText,
976
+ });
977
+ if (!sendResult.ok) {
978
+ throw new Error(sendResult.error || sendResult.reason);
979
+ }
980
+ deliveryMode = 'queued';
981
+ } else {
982
+ const sendResult = await sendPaneInput({
983
+ paneTarget: tmuxTarget,
984
+ prompt: markedText,
985
+ submitKeyPresses: 2,
986
+ submitDelayMs: 100,
987
+ });
988
+ if (!sendResult.ok) {
989
+ throw new Error(sendResult.error || sendResult.reason);
990
+ }
978
991
  }
979
992
  nudgeState.last_nudged_by_team[teamName] = {
980
993
  at: nowIso,
@@ -1005,6 +1018,7 @@ export async function maybeNudgeTeamLeader({
1005
1018
  message_count: messages.length,
1006
1019
  stalled_for_ms: undefined,
1007
1020
  missing_signal_workers: progressSnapshot.missingSignalWorkers,
1021
+ delivery: deliveryMode,
1008
1022
  });
1009
1023
  } catch { /* ignore */ }
1010
1024
  await appendTeamDeliveryLog(logsDir, {
@@ -1013,7 +1027,7 @@ export async function maybeNudgeTeamLeader({
1013
1027
  team: teamName,
1014
1028
  to_worker: 'leader-fixed',
1015
1029
  transport: 'send-keys',
1016
- result: 'sent',
1030
+ result: deliveryMode,
1017
1031
  reason: nudgeReason,
1018
1032
  orchestration_intent: orchestrationIntent,
1019
1033
  }).catch(() => {});
@@ -182,6 +182,48 @@ export async function sendPaneInput({
182
182
  }
183
183
  }
184
184
 
185
+ export async function queuePaneInput({
186
+ paneTarget,
187
+ prompt,
188
+ submitDelayMs = 80,
189
+ }: any): Promise<any> {
190
+ const sendResult = await sendPaneInput({
191
+ paneTarget,
192
+ prompt,
193
+ submitKeyPresses: 0,
194
+ });
195
+ if (!sendResult.ok) return sendResult;
196
+
197
+ const target = safeString(paneTarget).trim();
198
+ const submitArgv = [
199
+ ['send-keys', '-t', target, 'Tab'],
200
+ ['send-keys', '-t', target, 'C-m'],
201
+ ];
202
+ try {
203
+ await runProcess('tmux', submitArgv[0], 3000);
204
+ if (submitDelayMs > 0) {
205
+ await new Promise((resolve) => setTimeout(resolve, submitDelayMs));
206
+ }
207
+ await runProcess('tmux', submitArgv[1], 3000);
208
+ return {
209
+ ok: true,
210
+ sent: true,
211
+ reason: 'queued',
212
+ paneTarget: target,
213
+ argv: { typeArgv: sendResult.argv?.typeArgv || null, submitArgv },
214
+ };
215
+ } catch (error) {
216
+ return {
217
+ ok: false,
218
+ sent: false,
219
+ reason: 'queue_failed',
220
+ paneTarget: target,
221
+ argv: { typeArgv: sendResult.argv?.typeArgv || null, submitArgv },
222
+ error: error instanceof Error ? error.message : safeString(error),
223
+ };
224
+ }
225
+ }
226
+
185
227
  export async function checkPaneReadyForTeamSendKeys(paneTarget: any): Promise<any> {
186
228
  return evaluatePaneInjectionReadiness(paneTarget);
187
229
  }
@@ -8,12 +8,12 @@
8
8
 
9
9
  import { appendFile, mkdir, rename, writeFile } from 'fs/promises';
10
10
  import { dirname, join } from 'path';
11
- import { DEFAULT_MARKER } from '../tmux-hook-engine.js';
11
+ import { DEFAULT_MARKER, paneHasActiveTask } from '../tmux-hook-engine.js';
12
12
  import { appendTeamDeliveryLog } from '../../team/delivery-log.js';
13
13
  import { safeString, asNumber } from './utils.js';
14
14
  import { readJsonIfExists } from './state-io.js';
15
15
  import { logTmuxHookEvent } from './log.js';
16
- import { evaluatePaneInjectionReadiness, sendPaneInput } from './team-tmux-guard.js';
16
+ import { evaluatePaneInjectionReadiness, queuePaneInput, sendPaneInput } from './team-tmux-guard.js';
17
17
  import { resolvePaneTarget } from './tmux-injection.js';
18
18
  import { readTeamWorkersForIdleCheck } from './team-worker.js';
19
19
 
@@ -185,17 +185,28 @@ export async function maybeNudgeLeaderForAllowedWorkerStop({
185
185
  + DEFAULT_MARKER;
186
186
 
187
187
  try {
188
- const sendResult = await sendPaneInput({
189
- paneTarget: tmuxTarget,
190
- prompt,
191
- submitKeyPresses: 2,
192
- submitDelayMs: 100,
193
- });
194
- if (!sendResult.ok) throw new Error(sendResult.error || sendResult.reason || 'send_failed');
188
+ const leaderHasActiveTask = paneHasActiveTask(paneGuard.paneCapture);
189
+ let deliveryMode = 'sent';
190
+ if (leaderHasActiveTask) {
191
+ const sendResult = await queuePaneInput({
192
+ paneTarget: tmuxTarget,
193
+ prompt,
194
+ });
195
+ if (!sendResult.ok) throw new Error(sendResult.error || sendResult.reason || 'send_failed');
196
+ deliveryMode = 'queued';
197
+ } else {
198
+ const sendResult = await sendPaneInput({
199
+ paneTarget: tmuxTarget,
200
+ prompt,
201
+ submitKeyPresses: 2,
202
+ submitDelayMs: 100,
203
+ });
204
+ if (!sendResult.ok) throw new Error(sendResult.error || sendResult.reason || 'send_failed');
205
+ }
195
206
 
196
207
  await writeStopNudgeState(statePath, {
197
208
  ...nextState,
198
- delivery: 'sent',
209
+ delivery: deliveryMode,
199
210
  leader_pane_id: leaderPaneId || null,
200
211
  tmux_target: tmuxTarget,
201
212
  });
@@ -205,7 +216,7 @@ export async function maybeNudgeLeaderForAllowedWorkerStop({
205
216
  type: 'worker_stop_leader_nudge',
206
217
  worker: workerName,
207
218
  to_worker: 'leader-fixed',
208
- delivery: 'sent',
219
+ delivery: deliveryMode,
209
220
  created_at: nowIso,
210
221
  source_type: SOURCE_TYPE,
211
222
  });
@@ -225,10 +236,10 @@ export async function maybeNudgeLeaderForAllowedWorkerStop({
225
236
  from_worker: workerName,
226
237
  to_worker: 'leader-fixed',
227
238
  transport: 'send-keys',
228
- result: 'sent',
239
+ result: deliveryMode,
229
240
  reason: 'worker_stop_allowed',
230
241
  }).catch(() => {});
231
- return { ok: true, result: 'sent' };
242
+ return { ok: true, result: deliveryMode };
232
243
  } catch (err) {
233
244
  await recordDeferred({
234
245
  stateDir,
@@ -83,6 +83,16 @@ function shouldUseClaudeIssuePermissionsBypass(provider: string, prompt: string)
83
83
  return ISSUE_WORK_PROMPT_PATTERNS.some((pattern) => pattern.test(trimmed));
84
84
  }
85
85
 
86
+ function buildProviderLaunchArgs(provider: string, prompt: string, originalTask: string): string[] {
87
+ const promptArgs = provider === 'claude'
88
+ ? ['-p', '--', prompt]
89
+ : ['-p', prompt];
90
+
91
+ return shouldUseClaudeIssuePermissionsBypass(provider, originalTask)
92
+ ? [CLAUDE_SKIP_PERMISSIONS_FLAG, ...promptArgs]
93
+ : promptArgs;
94
+ }
95
+
86
96
  function buildSummary(exitCode: number, output: string): string {
87
97
  if (exitCode === 0) {
88
98
  return 'Provider completed successfully. Review the raw output for details.';
@@ -165,9 +175,7 @@ async function main(): Promise<void> {
165
175
 
166
176
  ensureBinary(binary);
167
177
 
168
- const launchArgs = shouldUseClaudeIssuePermissionsBypass(provider, originalTask)
169
- ? [CLAUDE_SKIP_PERMISSIONS_FLAG, '-p', prompt]
170
- : ['-p', prompt];
178
+ const launchArgs = buildProviderLaunchArgs(provider, prompt, originalTask);
171
179
 
172
180
  const run = spawnSync(binary, launchArgs, {
173
181
  encoding: 'utf8',
@@ -20,6 +20,8 @@ export {
20
20
  export const PACKED_INSTALL_SMOKE_CORE_COMMANDS = [
21
21
  ['--help'],
22
22
  ['version'],
23
+ ['api', '--help'],
24
+ ['sparkshell', '--help'],
23
25
  ] as const;
24
26
 
25
27
  function usage(): string {
@@ -108,6 +108,13 @@
108
108
  "core": false,
109
109
  "internalRequired": false
110
110
  },
111
+ {
112
+ "name": "best-practice-research",
113
+ "category": "planning",
114
+ "status": "active",
115
+ "core": false,
116
+ "internalRequired": false
117
+ },
111
118
  {
112
119
  "name": "analyze",
113
120
  "category": "shortcut",