@gajae-code/coding-agent 0.12.0 → 0.12.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.
Files changed (247) hide show
  1. package/CHANGELOG.md +111 -0
  2. package/dist/types/async/job-manager.d.ts +17 -3
  3. package/dist/types/config/settings-schema.d.ts +56 -2
  4. package/dist/types/exa/mcp-client.d.ts +10 -1
  5. package/dist/types/exec/bash-executor.d.ts +45 -12
  6. package/dist/types/extensibility/extensions/types.d.ts +16 -2
  7. package/dist/types/extensibility/gjc-plugins/index.d.ts +12 -2
  8. package/dist/types/extensibility/gjc-plugins/installer.d.ts +63 -7
  9. package/dist/types/extensibility/gjc-plugins/lifecycle-reconciliation.d.ts +55 -0
  10. package/dist/types/extensibility/gjc-plugins/lifecycle.d.ts +64 -0
  11. package/dist/types/extensibility/gjc-plugins/runtime-quarantine.d.ts +67 -0
  12. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +3 -1
  13. package/dist/types/extensibility/gjc-plugins/types.d.ts +124 -0
  14. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  15. package/dist/types/gjc-runtime/ralplan-review-conflicts.d.ts +114 -0
  16. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +23 -3
  17. package/dist/types/gjc-runtime/team-launch.d.ts +1 -1
  18. package/dist/types/gjc-runtime/team-runtime.d.ts +25 -0
  19. package/dist/types/gjc-runtime/team-worker-heartbeat.d.ts +43 -0
  20. package/dist/types/gjc-runtime/tmux-owner-isolation.d.ts +10 -0
  21. package/dist/types/gjc-runtime/ultragoal-runtime.d.ts +37 -80
  22. package/dist/types/modes/acp/acp-agent.d.ts +2 -19
  23. package/dist/types/modes/components/gjc-bundle-settings.d.ts +34 -0
  24. package/dist/types/modes/components/irc-sidebar.d.ts +16 -0
  25. package/dist/types/modes/components/notifications-settings-editor.d.ts +1 -0
  26. package/dist/types/modes/components/session-observer-overlay.d.ts +18 -0
  27. package/dist/types/modes/components/settings-selector.d.ts +9 -0
  28. package/dist/types/modes/components/status-line/gh.d.ts +5 -0
  29. package/dist/types/modes/components/transcript-viewer-overlay.d.ts +18 -1
  30. package/dist/types/modes/controllers/command-controller.d.ts +1 -1
  31. package/dist/types/modes/controllers/event-controller.d.ts +1 -1
  32. package/dist/types/modes/controllers/extension-ui-controller.d.ts +2 -1
  33. package/dist/types/modes/controllers/selector-controller.d.ts +1 -1
  34. package/dist/types/modes/interactive-mode.d.ts +9 -0
  35. package/dist/types/modes/theme/theme.d.ts +3 -1
  36. package/dist/types/modes/types.d.ts +32 -0
  37. package/dist/types/reminders/star-reminder.d.ts +3 -13
  38. package/dist/types/runtime-mcp/manager.d.ts +1 -0
  39. package/dist/types/sdk/acp/adapter.d.ts +3 -1
  40. package/dist/types/sdk/broker/transport.d.ts +2 -0
  41. package/dist/types/sdk/bus/attachment-registry.d.ts +1 -0
  42. package/dist/types/sdk/bus/chat-daemon-control.d.ts +5 -1
  43. package/dist/types/sdk/bus/config.d.ts +6 -2
  44. package/dist/types/sdk/bus/index.d.ts +29 -0
  45. package/dist/types/sdk/bus/kind-aware-reconciliation.d.ts +8 -1
  46. package/dist/types/sdk/bus/lifecycle-control-runtime.d.ts +4 -0
  47. package/dist/types/sdk/bus/reconciliation-store.d.ts +7 -3
  48. package/dist/types/sdk/bus/rich-draft.d.ts +1 -0
  49. package/dist/types/sdk/bus/rich-render.d.ts +2 -0
  50. package/dist/types/sdk/bus/telegram-adoption-intent.d.ts +165 -0
  51. package/dist/types/sdk/bus/telegram-cli.d.ts +12 -0
  52. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +12 -4
  53. package/dist/types/sdk/bus/telegram-daemon.d.ts +25 -1
  54. package/dist/types/sdk/bus/telegram-reference.d.ts +11 -1
  55. package/dist/types/sdk/bus/threaded-render.d.ts +3 -2
  56. package/dist/types/sdk/bus/topic-registry.d.ts +11 -1
  57. package/dist/types/sdk/host/query/revision-store.d.ts +2 -0
  58. package/dist/types/sdk/host/reverse-leases.d.ts +1 -1
  59. package/dist/types/sdk/prompt-status.d.ts +23 -3
  60. package/dist/types/sdk/session.d.ts +6 -0
  61. package/dist/types/session/agent-session.d.ts +40 -2
  62. package/dist/types/session/artifacts.d.ts +8 -0
  63. package/dist/types/session/blob-store.d.ts +57 -4
  64. package/dist/types/session/internal/managed-session-storage.d.ts +30 -4
  65. package/dist/types/session/session-manager.d.ts +23 -6
  66. package/dist/types/session/session-storage.d.ts +4 -0
  67. package/dist/types/session/streaming-output.d.ts +36 -7
  68. package/dist/types/setup/credential-import.d.ts +12 -0
  69. package/dist/types/skill-state/active-state.d.ts +12 -0
  70. package/dist/types/skill-state/workflow-hud.d.ts +6 -0
  71. package/dist/types/task/executor.d.ts +3 -2
  72. package/dist/types/task/index.d.ts +7 -1
  73. package/dist/types/task/output-manager.d.ts +1 -0
  74. package/dist/types/task/provider-retry-status.d.ts +8 -0
  75. package/dist/types/task/receipt.d.ts +9 -1
  76. package/dist/types/task/types.d.ts +23 -0
  77. package/dist/types/tools/ask-answer-registry.d.ts +2 -2
  78. package/dist/types/tools/bash-interactive.d.ts +4 -1
  79. package/dist/types/tools/bash.d.ts +3 -1
  80. package/dist/types/tools/image-gen.d.ts +4 -0
  81. package/dist/types/tools/index.d.ts +6 -0
  82. package/dist/types/tools/output-meta.d.ts +23 -2
  83. package/dist/types/tools/path-utils.d.ts +1 -0
  84. package/dist/types/tools/read.d.ts +0 -6
  85. package/dist/types/tools/review.d.ts +5 -7
  86. package/dist/types/tools/subagent.d.ts +2 -0
  87. package/dist/types/utils/gh.d.ts +13 -0
  88. package/dist/types/utils/image-resize.d.ts +1 -0
  89. package/dist/types/web/search/providers/kimi.d.ts +2 -0
  90. package/dist/types/web/search/providers/searxng.d.ts +7 -0
  91. package/dist/types/web/search/providers/xai.d.ts +2 -0
  92. package/package.json +10 -8
  93. package/scripts/acp-conformance-agent.ts +218 -0
  94. package/scripts/capture-gjc-bundle-settings.ts +306 -0
  95. package/scripts/dogfood-ralplan-review-conflicts.ts +272 -0
  96. package/scripts/generate-sdk-operation-inventory.ts +5 -0
  97. package/scripts/resident-memory-bench.ts +1341 -0
  98. package/scripts/run-acp-conformance.ts +290 -0
  99. package/src/async/job-manager.ts +99 -39
  100. package/src/cli/fast-help.ts +1 -1
  101. package/src/cli/plugin-cli.ts +176 -18
  102. package/src/commands/team.ts +19 -1
  103. package/src/config/file-lock.ts +11 -2
  104. package/src/config/settings-schema.ts +53 -2
  105. package/src/coordinator-mcp/question-gate-codec.ts +1 -1
  106. package/src/coordinator-mcp/server.ts +5 -12
  107. package/src/debug/index.ts +42 -17
  108. package/src/defaults/gjc/skills/deep-interview/SKILL.md +1 -0
  109. package/src/defaults/gjc/skills/ralplan/SKILL.md +25 -20
  110. package/src/defaults/gjc/skills/team/SKILL.md +2 -0
  111. package/src/defaults/gjc/skills/ultragoal/SKILL.md +39 -26
  112. package/src/defaults/gjc/skills/ultragoal/validation-batch-contracts.md +12 -0
  113. package/src/defaults/gjc-defaults.ts +4 -4
  114. package/src/exa/mcp-client.ts +12 -3
  115. package/src/exec/bash-executor.ts +144 -27
  116. package/src/extensibility/extensions/runner.ts +15 -0
  117. package/src/extensibility/extensions/types.ts +18 -1
  118. package/src/extensibility/gjc-plugins/constrained-hooks.ts +8 -2
  119. package/src/extensibility/gjc-plugins/index.ts +19 -2
  120. package/src/extensibility/gjc-plugins/installer.ts +189 -50
  121. package/src/extensibility/gjc-plugins/lifecycle-reconciliation.ts +199 -0
  122. package/src/extensibility/gjc-plugins/lifecycle.ts +626 -0
  123. package/src/extensibility/gjc-plugins/observability.ts +3 -3
  124. package/src/extensibility/gjc-plugins/prompt-appendix.ts +5 -1
  125. package/src/extensibility/gjc-plugins/runtime-adapters.ts +12 -2
  126. package/src/extensibility/gjc-plugins/runtime-quarantine.ts +121 -0
  127. package/src/extensibility/gjc-plugins/schema.ts +84 -12
  128. package/src/extensibility/gjc-plugins/session-validation.ts +9 -3
  129. package/src/extensibility/gjc-plugins/types.ts +141 -0
  130. package/src/extensibility/gjc-plugins/validation.ts +9 -0
  131. package/src/gjc-runtime/launch-tmux.ts +25 -6
  132. package/src/gjc-runtime/launch-worktree.ts +103 -1
  133. package/src/gjc-runtime/ledger-event-renderer.ts +1 -0
  134. package/src/gjc-runtime/managed-owner-admission.ts +1 -1
  135. package/src/gjc-runtime/ralplan-review-conflicts.ts +472 -0
  136. package/src/gjc-runtime/ralplan-runtime.ts +491 -27
  137. package/src/gjc-runtime/team-launch.ts +8 -0
  138. package/src/gjc-runtime/team-runtime.ts +140 -32
  139. package/src/gjc-runtime/team-worker-heartbeat.ts +149 -0
  140. package/src/gjc-runtime/tmux-owner-isolation.ts +10 -0
  141. package/src/gjc-runtime/tmux-sessions.ts +58 -13
  142. package/src/gjc-runtime/ultragoal-evidence.ts +6 -74
  143. package/src/gjc-runtime/ultragoal-runtime.ts +635 -612
  144. package/src/gjc-runtime/workflow-manifest.generated.json +37 -99
  145. package/src/gjc-runtime/workflow-manifest.ts +21 -70
  146. package/src/internal-urls/docs-index.generated.ts +16 -16
  147. package/src/modes/DESIGN.md +104 -0
  148. package/src/modes/acp/acp-agent.ts +298 -186
  149. package/src/modes/components/gjc-bundle-settings.ts +508 -0
  150. package/src/modes/components/irc-sidebar.ts +30 -1
  151. package/src/modes/components/notifications-settings-editor.ts +12 -1
  152. package/src/modes/components/oauth-selector.ts +68 -30
  153. package/src/modes/components/session-observer-overlay.ts +305 -86
  154. package/src/modes/components/settings-selector.ts +56 -5
  155. package/src/modes/components/status-line/gh.ts +16 -0
  156. package/src/modes/components/tool-status-header.ts +3 -15
  157. package/src/modes/components/transcript-viewer-overlay.ts +99 -24
  158. package/src/modes/controllers/command-controller.ts +42 -34
  159. package/src/modes/controllers/event-controller.ts +27 -16
  160. package/src/modes/controllers/extension-ui-controller.ts +193 -34
  161. package/src/modes/controllers/selector-controller.ts +160 -22
  162. package/src/modes/interactive-mode.ts +189 -62
  163. package/src/modes/jobs-observer.ts +1 -1
  164. package/src/modes/runtime-init.ts +3 -0
  165. package/src/modes/theme/theme.ts +11 -9
  166. package/src/modes/types.ts +88 -0
  167. package/src/prompts/agent-fragments/ralplan-persistence.md +4 -3
  168. package/src/prompts/system/eager-todo.md +8 -4
  169. package/src/prompts/system/system-prompt.md +11 -4
  170. package/src/prompts/tools/bash.md +5 -5
  171. package/src/prompts/tools/search-tool-bm25.md +5 -0
  172. package/src/prompts/tools/task.md +1 -1
  173. package/src/reminders/star-reminder.ts +4 -42
  174. package/src/runtime-mcp/manager.ts +19 -4
  175. package/src/sdk/acp/adapter.ts +22 -6
  176. package/src/sdk/broker/lifecycle.ts +18 -13
  177. package/src/sdk/broker/transport.ts +35 -3
  178. package/src/sdk/bus/attachment-registry.ts +5 -1
  179. package/src/sdk/bus/chat-daemon-cli.ts +4 -39
  180. package/src/sdk/bus/chat-daemon-control.ts +7 -3
  181. package/src/sdk/bus/config.ts +18 -2
  182. package/src/sdk/bus/index.ts +661 -126
  183. package/src/sdk/bus/kind-aware-reconciliation.ts +148 -73
  184. package/src/sdk/bus/lifecycle-commands.ts +4 -1
  185. package/src/sdk/bus/lifecycle-control-runtime.ts +403 -88
  186. package/src/sdk/bus/reconciliation-store.ts +95 -16
  187. package/src/sdk/bus/rich-draft.ts +1 -1
  188. package/src/sdk/bus/rich-render.ts +2 -2
  189. package/src/sdk/bus/telegram-adoption-intent.ts +574 -0
  190. package/src/sdk/bus/telegram-cli.ts +33 -8
  191. package/src/sdk/bus/telegram-daemon-cli.ts +1 -0
  192. package/src/sdk/bus/telegram-daemon-contract.ts +12 -4
  193. package/src/sdk/bus/telegram-daemon.ts +916 -23
  194. package/src/sdk/bus/telegram-reference.ts +40 -3
  195. package/src/sdk/bus/threaded-render.ts +48 -3
  196. package/src/sdk/bus/topic-registry.ts +20 -0
  197. package/src/sdk/host/query/revision-store.ts +36 -2
  198. package/src/sdk/host/reverse-leases.ts +44 -7
  199. package/src/sdk/prompt-status.ts +16 -3
  200. package/src/sdk/protocol/operation-inventory.generated.json +33 -0
  201. package/src/sdk/session.ts +89 -9
  202. package/src/session/agent-session.ts +844 -173
  203. package/src/session/artifacts.ts +22 -0
  204. package/src/session/auth-broker-config.ts +10 -3
  205. package/src/session/blob-store.ts +915 -24
  206. package/src/session/internal/managed-session-scope.ts +200 -111
  207. package/src/session/internal/managed-session-storage.ts +492 -62
  208. package/src/session/session-manager.ts +1614 -435
  209. package/src/session/session-storage.ts +38 -45
  210. package/src/session/streaming-output.ts +238 -68
  211. package/src/setup/credential-import.ts +75 -13
  212. package/src/skill-state/active-state.ts +32 -0
  213. package/src/skill-state/workflow-hud.ts +17 -0
  214. package/src/skill-state/workflow-mutation-guard.ts +496 -2
  215. package/src/task/executor.ts +122 -39
  216. package/src/task/index.ts +280 -45
  217. package/src/task/output-manager.ts +24 -14
  218. package/src/task/provider-retry-status.ts +16 -0
  219. package/src/task/receipt.ts +34 -8
  220. package/src/task/render.ts +6 -2
  221. package/src/task/types.ts +108 -0
  222. package/src/tools/ask-answer-registry.ts +12 -6
  223. package/src/tools/ask.ts +86 -55
  224. package/src/tools/ast-edit.ts +1 -0
  225. package/src/tools/ast-grep.ts +1 -0
  226. package/src/tools/bash-interactive.ts +8 -3
  227. package/src/tools/bash.ts +503 -60
  228. package/src/tools/find.ts +1 -0
  229. package/src/tools/image-gen.ts +29 -5
  230. package/src/tools/index.ts +6 -0
  231. package/src/tools/output-meta.ts +108 -14
  232. package/src/tools/path-utils.ts +3 -2
  233. package/src/tools/read.ts +18 -0
  234. package/src/tools/review.ts +5 -7
  235. package/src/tools/search.ts +1 -0
  236. package/src/tools/subagent-render.ts +6 -2
  237. package/src/tools/subagent.ts +6 -0
  238. package/src/tools/telegram-send.ts +6 -3
  239. package/src/utils/gh.ts +39 -0
  240. package/src/utils/image-resize.ts +3 -0
  241. package/src/web/search/providers/anthropic.ts +4 -4
  242. package/src/web/search/providers/kimi.ts +15 -5
  243. package/src/web/search/providers/searxng.ts +47 -4
  244. package/src/web/search/providers/xai.ts +7 -2
  245. package/dist/types/gjc-runtime/ultragoal-pipeline.d.ts +0 -23
  246. package/src/defaults/gjc/skills/ultragoal/pipeline-validation-contracts.md +0 -30
  247. package/src/gjc-runtime/ultragoal-pipeline.ts +0 -306
@@ -0,0 +1,290 @@
1
+ #!/usr/bin/env bun
2
+
3
+ import * as fs from "node:fs/promises";
4
+ import * as os from "node:os";
5
+ import * as path from "node:path";
6
+
7
+ export const ACPX_VERSION = "0.13.0";
8
+ export const ACPX_GIT_HEAD = "47dc1c56b20da3c248a4a1b5c5106f52e65e6594";
9
+ /** `conformance/profiles/acp-core-v1.json#required_cases` at the pinned commit. */
10
+ export const ACP_CORE_V1_CASE_IDS = [
11
+ "acp.v1.initialize.handshake",
12
+ "acp.v1.session.new.basic",
13
+ "acp.v1.session.prompt.single_turn",
14
+ "acp.v1.session.update.termination",
15
+ "acp.v1.session.cancel.in_flight",
16
+ "acp.v1.session.cancel.idle",
17
+ "acp.v1.session.prompt.multi_turn",
18
+ "acp.v1.errors.invalid_params",
19
+ "acp.v1.errors.invalid_prompt_session_type",
20
+ "acp.v1.errors.permission_denied",
21
+ "acp.v1.errors.permission_denied.write",
22
+ "acp.v1.errors.unknown_session",
23
+ "acp.v1.session.prompt.echo_empty",
24
+ "acp.v1.session.prompt.unrecognized",
25
+ "acp.v1.errors.invalid_params.cwd_null",
26
+ "acp.v1.session.prompt.structured_blocks",
27
+ "acp.v1.permissions.read.approved",
28
+ "acp.v1.permissions.write.approved",
29
+ "acp.v1.session.prompt.background_completion",
30
+ "acp.v1.session.cancel.followup_prompt",
31
+ "acp.v1.session.prompt.post_success_drain",
32
+ ] as const;
33
+
34
+ export type AcpxMetadata = { version: string; gitHead?: string };
35
+ export type AcpxCheckout = { root: string; head: string };
36
+ export type CaseResult = { id: string; passed: boolean; [key: string]: unknown };
37
+ export type UpstreamReport = {
38
+ profileId: string;
39
+ results: CaseResult[];
40
+ totals?: { cases: number; passed: number; failed: number };
41
+ [key: string]: unknown;
42
+ };
43
+ export type ConformanceReport = {
44
+ command: string[];
45
+ cwd: string;
46
+ gjc: { commit: string; dirty: boolean };
47
+ acpx: { version: string; gitHead: string };
48
+ profile: string;
49
+ agentCommand: string;
50
+ matrix: CaseResult[];
51
+ totals: { cases: number; passed: number; failed: number };
52
+ };
53
+
54
+ export interface RunAcpConformanceOptions {
55
+ agentCommand: string;
56
+ reportPath: string;
57
+ format?: "json";
58
+ cwd?: string;
59
+ profile?: string;
60
+ fetchMetadata?: () => Promise<AcpxMetadata>;
61
+ checkout?: () => Promise<AcpxCheckout>;
62
+ runRunner?: (options: { command: string[]; cwd: string; sessionCwd?: string }) => Promise<void>;
63
+ readReport?: (reportPath: string) => Promise<unknown>;
64
+ writeReport?: (reportPath: string, report: ConformanceReport) => Promise<void>;
65
+ }
66
+
67
+ function assertProvenance(metadata: AcpxMetadata, checkout: AcpxCheckout): void {
68
+ if (metadata.version !== ACPX_VERSION)
69
+ throw new Error(`Expected acpx@${ACPX_VERSION}; received ${metadata.version}.`);
70
+ if (metadata.gitHead !== ACPX_GIT_HEAD)
71
+ throw new Error(`Expected acpx gitHead ${ACPX_GIT_HEAD}; received ${metadata.gitHead ?? "missing"}.`);
72
+ if (checkout.head !== ACPX_GIT_HEAD)
73
+ throw new Error(`Expected source checkout HEAD ${ACPX_GIT_HEAD}; received ${checkout.head}.`);
74
+ }
75
+
76
+ function validateResults(report: unknown, profile: string): CaseResult[] {
77
+ if (!report || typeof report !== "object") throw new Error("Conformance runner report is missing or malformed.");
78
+ const value = report as Partial<UpstreamReport>;
79
+ if (value.profileId !== profile || !Array.isArray(value.results))
80
+ throw new Error("Conformance runner report is missing profileId or results.");
81
+ const expected = new Set<string>(ACP_CORE_V1_CASE_IDS);
82
+ const seen = new Set<string>();
83
+ for (const result of value.results) {
84
+ if (!result || typeof result.id !== "string" || typeof result.passed !== "boolean")
85
+ throw new Error("Conformance runner report contains a malformed case result.");
86
+ if (seen.has(result.id)) throw new Error(`Conformance runner report has duplicate case ID: ${result.id}.`);
87
+ seen.add(result.id);
88
+ if (!expected.has(result.id)) throw new Error(`Conformance runner report has unexpected case ID: ${result.id}.`);
89
+ if (!result.passed) throw new Error(`Conformance case failed: ${result.id}.`);
90
+ }
91
+ for (const id of ACP_CORE_V1_CASE_IDS)
92
+ if (!seen.has(id)) throw new Error(`Conformance runner report is missing required case ID: ${id}.`);
93
+ return value.results;
94
+ }
95
+
96
+ /**
97
+ * Runs the pinned upstream conformance suite against a real ACP agent command.
98
+ *
99
+ * `--cwd` must be a real path, not one reached through a symlink (macOS `/tmp` is a
100
+ * link to `/private/tmp`): the upstream client enforces its session cwd root against
101
+ * the resolved path, so a symlinked workspace fails the client-authority cases.
102
+ */
103
+ export async function runAcpConformance(options: RunAcpConformanceOptions): Promise<ConformanceReport> {
104
+ const profile = options.profile ?? "acp-core-v1";
105
+ const requestedCwd = options.cwd ?? process.cwd();
106
+ const resolvedCwd = await fs.realpath(requestedCwd).catch(() => requestedCwd);
107
+ if (path.resolve(requestedCwd) !== resolvedCwd)
108
+ throw new Error(
109
+ `--cwd must be a real path; ${requestedCwd} resolves to ${resolvedCwd} and the client rejects paths outside its session cwd root.`,
110
+ );
111
+ if (profile !== "acp-core-v1") throw new Error(`Unsupported conformance profile: ${profile}.`);
112
+ // The upstream runner spawns the agent from its own checkout, so any repo-relative
113
+ // path in the command must be resolved against this repository first.
114
+ const agentCommand = options.agentCommand
115
+ .split(" ")
116
+ .map(token => (token.endsWith(".ts") && !path.isAbsolute(token) ? path.resolve(token) : token))
117
+ .join(" ");
118
+ const metadata = await (options.fetchMetadata ?? fetchNpmMetadata)();
119
+ const checkout = await (options.checkout ?? checkoutAcpxSource)();
120
+ assertProvenance(metadata, checkout);
121
+ const upstreamReportPath = path.join(os.tmpdir(), `gjc-acpx-${crypto.randomUUID()}.json`);
122
+ const command = [
123
+ "bun",
124
+ path.join(checkout.root, "conformance", "runner", "run.ts"),
125
+ "--profile",
126
+ path.join(checkout.root, "conformance", "profiles", `${profile}.json`),
127
+ "--cases-dir",
128
+ path.join(checkout.root, "conformance", "cases"),
129
+ "--agent-command",
130
+ agentCommand,
131
+ "--format",
132
+ "json",
133
+ "--report",
134
+ upstreamReportPath,
135
+ "--cwd",
136
+ options.cwd ?? process.cwd(),
137
+ ];
138
+ // A failing run still writes its report. Preserve that matrix as CI evidence before
139
+ // surfacing the failure, so the uploaded artifact explains what actually broke.
140
+ let runnerFailure: unknown;
141
+ try {
142
+ await (options.runRunner ?? runRunner)({ command, cwd: checkout.root, sessionCwd: options.cwd ?? process.cwd() });
143
+ } catch (error) {
144
+ runnerFailure = error;
145
+ }
146
+ const upstream = await (options.readReport ?? readReport)(upstreamReportPath).catch(() => undefined);
147
+ if (runnerFailure !== undefined) {
148
+ if (upstream && typeof upstream === "object") {
149
+ const failed = upstream as Partial<UpstreamReport>;
150
+ await (options.writeReport ?? writeReport)(options.reportPath, {
151
+ command,
152
+ cwd: options.cwd ?? process.cwd(),
153
+ gjc: gjcIdentity(),
154
+ acpx: { version: metadata.version, gitHead: metadata.gitHead as string },
155
+ profile,
156
+ agentCommand,
157
+ matrix: Array.isArray(failed.results) ? failed.results : [],
158
+ totals: failed.totals ?? { cases: 0, passed: 0, failed: 0 },
159
+ });
160
+ }
161
+ throw runnerFailure;
162
+ }
163
+ const matrix = validateResults(upstream, profile);
164
+ const report: ConformanceReport = {
165
+ command,
166
+ cwd: options.cwd ?? process.cwd(),
167
+ gjc: gjcIdentity(),
168
+ acpx: { version: metadata.version, gitHead: metadata.gitHead! },
169
+ profile,
170
+ agentCommand,
171
+ matrix,
172
+ totals: { cases: matrix.length, passed: matrix.length, failed: 0 },
173
+ };
174
+ await (options.writeReport ?? writeReport)(options.reportPath, report);
175
+ return report;
176
+ }
177
+
178
+ function gjcIdentity(): { commit: string; dirty: boolean } {
179
+ try {
180
+ const commit = Bun.spawnSync(["git", "rev-parse", "HEAD"]).stdout.toString().trim();
181
+ const dirty = Bun.spawnSync(["git", "status", "--porcelain"]).stdout.toString().trim().length > 0;
182
+ return { commit, dirty };
183
+ } catch {
184
+ return { commit: "unknown", dirty: true };
185
+ }
186
+ }
187
+
188
+ async function fetchNpmMetadata(): Promise<AcpxMetadata> {
189
+ const response = await fetch(`https://registry.npmjs.org/acpx/${ACPX_VERSION}`);
190
+ if (!response.ok)
191
+ throw new Error(`Unable to resolve acpx@${ACPX_VERSION}: ${response.status} ${response.statusText}.`);
192
+ return (await response.json()) as AcpxMetadata;
193
+ }
194
+
195
+ async function commandOutput(command: string[], cwd?: string, env?: Record<string, string>): Promise<string> {
196
+ const child = Bun.spawn(command, {
197
+ cwd,
198
+ stdout: "pipe",
199
+ stderr: "pipe",
200
+ ...(env ? { env: { ...process.env, ...env } } : {}),
201
+ });
202
+ const [code, stdout, stderr] = await Promise.all([
203
+ child.exited,
204
+ new Response(child.stdout).text(),
205
+ new Response(child.stderr).text(),
206
+ ]);
207
+ if (code !== 0)
208
+ throw new Error(`${command.join(" ")} failed (exit ${code}).\nstdout:\n${stdout}\nstderr:\n${stderr}`);
209
+ return stdout.trim();
210
+ }
211
+
212
+ async function checkoutAcpxSource(): Promise<AcpxCheckout> {
213
+ const root = path.join(os.homedir(), ".cache", "gjc", "acpx", ACPX_GIT_HEAD);
214
+ try {
215
+ await fs.access(path.join(root, ".git"));
216
+ } catch {
217
+ await fs.mkdir(path.dirname(root), { recursive: true });
218
+ await commandOutput(["git", "clone", "https://github.com/openclaw/acpx.git", root]);
219
+ }
220
+ await commandOutput(["git", "fetch", "--depth", "1", "origin", ACPX_GIT_HEAD], root);
221
+ await commandOutput(["git", "checkout", "--detach", ACPX_GIT_HEAD], root);
222
+ // A reused cache must match the pin exactly; local edits would invalidate the
223
+ // provenance the report claims.
224
+ await commandOutput(["git", "reset", "--hard", ACPX_GIT_HEAD], root);
225
+ await commandOutput(["git", "clean", "-fdx", "--exclude=node_modules"], root);
226
+ const status = await commandOutput(["git", "status", "--porcelain"], root);
227
+ if (status.length > 0) throw new Error(`acpx source checkout is not clean at ${ACPX_GIT_HEAD}.`);
228
+ // The upstream runner resolves its own imports (`@agentclientprotocol/sdk`, `zod`)
229
+ // from this checkout, not from the gjc workspace, so the pin has to be installed
230
+ // before it can run. `git clean` deliberately preserves node_modules so a warm
231
+ // cache skips the reinstall.
232
+ await commandOutput(["bun", "install", "--no-save", "--ignore-scripts"], root);
233
+ return { root, head: await commandOutput(["git", "rev-parse", "HEAD"], root) };
234
+ }
235
+
236
+ async function runRunner(options: { command: string[]; cwd: string; sessionCwd?: string }): Promise<void> {
237
+ // The fixture seeds the corpus scratch workspace; the runner only creates it.
238
+ await commandOutput(
239
+ options.command,
240
+ options.cwd,
241
+ options.sessionCwd ? { GJC_ACP_CONFORMANCE_CWD: options.sessionCwd } : undefined,
242
+ );
243
+ }
244
+ async function readReport(reportPath: string): Promise<unknown> {
245
+ return await Bun.file(reportPath).json();
246
+ }
247
+ async function writeReport(reportPath: string, report: ConformanceReport): Promise<void> {
248
+ await fs.mkdir(path.dirname(reportPath), { recursive: true });
249
+ await Bun.write(reportPath, `${JSON.stringify(report, null, 2)}\n`);
250
+ }
251
+
252
+ function parseCli(argv: string[]): RunAcpConformanceOptions {
253
+ const values: Partial<RunAcpConformanceOptions> = { format: "json", profile: "acp-core-v1" };
254
+ for (let index = 0; index < argv.length; index++) {
255
+ const flag = argv[index];
256
+ if (flag === "--format") {
257
+ if (argv[++index] !== "json") throw new Error("--format must be json.");
258
+ continue;
259
+ }
260
+ if (flag === "--agent-command") {
261
+ values.agentCommand = argv[++index];
262
+ continue;
263
+ }
264
+ if (flag === "--report") {
265
+ values.reportPath = argv[++index];
266
+ continue;
267
+ }
268
+ if (flag === "--cwd") {
269
+ values.cwd = argv[++index];
270
+ continue;
271
+ }
272
+ if (flag === "--profile") {
273
+ values.profile = argv[++index];
274
+ continue;
275
+ }
276
+ throw new Error(`Unknown argument: ${flag}.`);
277
+ }
278
+ if (!values.agentCommand || !values.reportPath)
279
+ throw new Error(
280
+ "Usage: conformance:run --agent-command <command> --format json --report <path> [--cwd <path>] [--profile acp-core-v1]",
281
+ );
282
+ return values as RunAcpConformanceOptions;
283
+ }
284
+
285
+ if (import.meta.main) {
286
+ runAcpConformance(parseCli(process.argv.slice(2))).catch(error => {
287
+ process.stderr.write(`${error instanceof Error ? error.message : String(error)}\n`);
288
+ process.exitCode = 1;
289
+ });
290
+ }
@@ -32,6 +32,8 @@ export interface AsyncJob {
32
32
  promise: Promise<void>;
33
33
  resultText?: string;
34
34
  errorText?: string;
35
+ /** Safe, bounded cause when session setup failed before the LLM began work. */
36
+ setupFailureSummary?: string;
35
37
  metadata?: AsyncJobMetadata;
36
38
  /**
37
39
  * Registry id of the agent that registered the job (e.g. "0-Main",
@@ -69,9 +71,12 @@ export interface AsyncJobMetadata {
69
71
  * non-terminal and non-delivering: the run suspended at a safe boundary and the
70
72
  * subagent can be resumed from its persisted sessionFile. `completed` always
71
73
  * wins a race with a late pause because the run returns it once it has actually
72
- * finished.
74
+ * finished. A `failed` outcome retains a safe setup diagnostic for receipt rendering.
73
75
  */
74
- export type SubagentRunOutcome = { kind: "completed"; text: string } | { kind: "paused"; note?: string };
76
+ export type SubagentRunOutcome =
77
+ | { kind: "completed"; text: string }
78
+ | { kind: "failed"; text: string; setupFailureSummary?: string }
79
+ | { kind: "paused"; note?: string };
75
80
 
76
81
  /** Canonical lifecycle of a subagent across pause/resume cycles. */
77
82
  export type SubagentLifecycle = "running" | "paused" | "queued" | "completed" | "failed" | "cancelled";
@@ -146,7 +151,11 @@ export interface SubagentRecord {
146
151
  historicalJobIds: string[];
147
152
  status: SubagentLifecycle;
148
153
  sessionFile: string | null;
149
- /** False for ephemeral sessions (no persistent artifacts dir). */
154
+ /**
155
+ * Explicit veto, not a complete availability result. False always denies;
156
+ * true still requires an owner-compatible descriptor or non-blank session
157
+ * file, followed by a separately available runner (`no_runner` otherwise).
158
+ */
150
159
  resumable: boolean;
151
160
  queued?: { ownerId?: string; seq: number; message?: string; createdAt: number };
152
161
  /** Resolved model the subagent was asked to use, e.g. "openai-codex/gpt-5.5". */
@@ -176,6 +185,22 @@ function sessionFileFromResumeDescriptorData(data: unknown): string | null {
176
185
  return typeof sessionFile === "string" && sessionFile.trim().length > 0 ? sessionFile : null;
177
186
  }
178
187
 
188
+ /**
189
+ * Derive retained context from an already owner-compatible descriptor or a
190
+ * legacy session file. A descriptor is sufficient even when `sessionFile` is
191
+ * null because the descriptor is the payload the resume runner consumes.
192
+ */
193
+ function hasRetainedResumeContext(input: {
194
+ resumable: boolean;
195
+ sessionFile: string | null;
196
+ descriptor: ResumeDescriptor | undefined;
197
+ }): boolean {
198
+ if (!input.resumable) return false;
199
+ return (
200
+ input.descriptor !== undefined || (typeof input.sessionFile === "string" && input.sessionFile.trim().length > 0)
201
+ );
202
+ }
203
+
179
204
  /** A pending resume awaiting a free concurrency slot. */
180
205
  interface ResumeQueueEntry {
181
206
  subagentId: string;
@@ -499,21 +524,34 @@ export class AsyncJobManager {
499
524
  typeof result === "string" ? { kind: "completed", text: result } : result;
500
525
 
501
526
  if (job.status === "cancelled") {
502
- job.resultText = outcome.kind === "completed" ? outcome.text : outcome.note;
527
+ job.resultText = outcome.kind === "paused" ? outcome.note : outcome.text;
528
+ this.#markRecordTerminal(id, "cancelled");
503
529
  this.#runLifecycle(id, "terminal", job);
504
530
  this.#scheduleEviction(id);
505
- this.#markRecordTerminal(id, "cancelled");
506
531
  this.#drainResumeQueue();
507
532
  return;
508
533
  }
509
534
  if (outcome.kind === "paused") {
510
535
  // Sole canonical writer of the running -> paused transition. No
511
536
  // delivery and no eviction scheduling: a paused subagent stays
512
- // listed and resumable from its sessionFile.
537
+ // listed and resumable from its retained resume context.
513
538
  job.status = "paused";
514
539
  this.#freezeEndTime(job);
515
540
  if (outcome.note) job.resultText = outcome.note;
516
541
  this.#markRecordPaused(id);
542
+ this.#notifyChange();
543
+ this.#drainResumeQueue();
544
+ return;
545
+ }
546
+ if (outcome.kind === "failed") {
547
+ job.status = "failed";
548
+ job.setupFailureSummary = outcome.setupFailureSummary;
549
+ this.#freezeEndTime(job);
550
+ job.errorText = outcome.text;
551
+ this.#markRecordTerminal(id, "failed");
552
+ this.#enqueueDelivery(id, outcome.text);
553
+ this.#runLifecycle(id, "terminal", job);
554
+ this.#scheduleEviction(id);
517
555
  this.#drainResumeQueue();
518
556
  return;
519
557
  }
@@ -521,28 +559,28 @@ export class AsyncJobManager {
521
559
  job.status = "completed";
522
560
  this.#freezeEndTime(job);
523
561
  job.resultText = outcome.text;
562
+ this.#markRecordTerminal(id, "completed");
524
563
  this.#enqueueDelivery(id, outcome.text);
525
564
  this.#runLifecycle(id, "terminal", job);
526
565
  this.#scheduleEviction(id);
527
- this.#markRecordTerminal(id, "completed");
528
566
  this.#drainResumeQueue();
529
567
  } catch (error) {
530
568
  if (job.status === "cancelled") {
531
569
  job.errorText = error instanceof Error ? error.message : String(error);
570
+ this.#markRecordTerminal(id, "cancelled");
532
571
  this.#runLifecycle(id, "terminal", job);
533
572
  this.#scheduleEviction(id);
534
- this.#markRecordTerminal(id, "cancelled");
535
573
  this.#drainResumeQueue();
536
574
  return;
537
575
  }
538
- this.#runLifecycle(id, "terminal", job);
539
576
  const errorText = error instanceof Error ? error.message : String(error);
540
577
  job.status = "failed";
541
578
  this.#freezeEndTime(job);
542
579
  job.errorText = errorText;
580
+ this.#markRecordTerminal(id, "failed");
543
581
  this.#enqueueDelivery(id, errorText);
582
+ this.#runLifecycle(id, "terminal", job);
544
583
  this.#scheduleEviction(id);
545
- this.#markRecordTerminal(id, "failed");
546
584
  this.#drainResumeQueue();
547
585
  }
548
586
  })();
@@ -562,20 +600,22 @@ export class AsyncJobManager {
562
600
  if (!job) return false;
563
601
  if (filter?.ownerId && job.ownerId !== filter.ownerId) return false;
564
602
  if (job.status === "paused") {
565
- this.#runLifecycle(id, "cancel");
566
603
  // Paused jobs have no running promise to abort; transition directly.
567
604
  // The session file is kept, so the record stays resumable by id.
568
605
  job.status = "cancelled";
569
606
  this.#markRecordTerminal(id, "cancelled");
607
+ this.#runLifecycle(id, "cancel");
570
608
  this.#scheduleEviction(id);
571
609
  this.#drainResumeQueue();
572
610
  return true;
573
611
  }
574
612
  if (job.status !== "running") return false;
575
- this.#runLifecycle(id, "cancel");
576
613
  job.status = "cancelled";
577
614
  this.#freezeEndTime(job);
615
+ this.#markRecordTerminal(id, "cancelled");
616
+ this.#runLifecycle(id, "cancel");
578
617
  job.abortController.abort();
618
+ this.#notifyChange();
579
619
  return true;
580
620
  }
581
621
 
@@ -659,6 +699,7 @@ export class AsyncJobManager {
659
699
  /** Register or replace the canonical record for a subagent. */
660
700
  registerSubagentRecord(record: SubagentRecord): void {
661
701
  this.#subagentRecords.set(record.subagentId, record);
702
+ this.#notifyChange();
662
703
  }
663
704
 
664
705
  /** Patch model metadata onto an existing subagent record (best-effort; no-op if unknown). */
@@ -684,7 +725,9 @@ export class AsyncJobManager {
684
725
  historicalJobIds: [],
685
726
  status: "completed",
686
727
  sessionFile,
687
- resumable: sessionFile !== null,
728
+ // The synthesized record copies this descriptor's owner, so the
729
+ // descriptor is owner-compatible with the record by construction.
730
+ resumable: hasRetainedResumeContext({ resumable: true, sessionFile, descriptor }),
688
731
  };
689
732
  this.#subagentRecords.set(record.subagentId, record);
690
733
  return record;
@@ -765,13 +808,24 @@ export class AsyncJobManager {
765
808
  }
766
809
 
767
810
  /**
768
- * Resolve the resume runner for a subagent: prefer the per-descriptor runner
769
- * captured at registration time (the originating parent's execution authority),
770
- * falling back to the process-global runner only for descriptors registered
771
- * without one.
811
+ * Resolve a descriptor only when `ownerId` matches the record by strict
812
+ * equality. Two undefined owners match; a distinguishably foreign descriptor
813
+ * is treated as absent for eligibility, runner selection, and execution.
814
+ */
815
+ #descriptorForRecord(rec: SubagentRecord): ResumeDescriptor | undefined {
816
+ const descriptor = this.#resumeDescriptors.get(rec.subagentId);
817
+ return descriptor !== undefined && descriptor.ownerId === rec.ownerId ? descriptor : undefined;
818
+ }
819
+
820
+ /**
821
+ * Resolve the resume runner for a record and its already owner-compatible
822
+ * descriptor. Per-descriptor authority is unavailable when the descriptor is foreign.
772
823
  */
773
- #resolveResumeRunner(subagentId: string): ResumeRunner | undefined {
774
- return this.#descriptorResumeRunners.get(subagentId) ?? this.#resumeRunner;
824
+ #resolveResumeRunner(rec: SubagentRecord, descriptor: ResumeDescriptor | undefined): ResumeRunner | undefined {
825
+ return (
826
+ (descriptor !== undefined ? this.#descriptorResumeRunners.get(rec.subagentId) : undefined) ??
827
+ this.#resumeRunner
828
+ );
775
829
  }
776
830
 
777
831
  getResumeDescriptor(subagentId: string, filter?: AsyncJobFilter): ResumeDescriptor | undefined {
@@ -1045,7 +1099,11 @@ export class AsyncJobManager {
1045
1099
  return { ok: true, status: rec.status };
1046
1100
  }
1047
1101
 
1048
- /** Resume a non-running subagent from its sessionFile, optionally injecting a message first. */
1102
+ /**
1103
+ * Resume a non-running subagent from retained context: an owner-compatible
1104
+ * descriptor or a legacy session file. Workflow routing keeps `not_found`,
1105
+ * `context_unavailable`, `no_runner`, and `resume_failed` distinct.
1106
+ */
1049
1107
  resumeSubagent(
1050
1108
  subagentId: string,
1051
1109
  filter?: AsyncJobFilter,
@@ -1066,8 +1124,11 @@ export class AsyncJobManager {
1066
1124
  }
1067
1125
  return { ok: false, status: "queued", reason: "already_queued" };
1068
1126
  }
1069
- if (!rec.resumable || !rec.sessionFile) return { ok: false, reason: "context_unavailable" };
1070
- if (!this.#resolveResumeRunner(rec.subagentId)) return { ok: false, reason: "no_runner" };
1127
+ const descriptor = this.#descriptorForRecord(rec);
1128
+ if (!hasRetainedResumeContext({ resumable: rec.resumable, sessionFile: rec.sessionFile, descriptor })) {
1129
+ return { ok: false, reason: "context_unavailable" };
1130
+ }
1131
+ if (!this.#resolveResumeRunner(rec, descriptor)) return { ok: false, reason: "no_runner" };
1071
1132
  if (this.getRunningJobs().length >= this.#maxRunningJobs) {
1072
1133
  const seq = ++this.#resumeSeq;
1073
1134
  rec.status = "queued";
@@ -1079,14 +1140,16 @@ export class AsyncJobManager {
1079
1140
  message,
1080
1141
  createdAt: rec.queued.createdAt,
1081
1142
  });
1143
+ this.#notifyChange();
1082
1144
  return { ok: true, queued: true, status: "queued" };
1083
1145
  }
1084
- return this.#startResume(rec, message);
1146
+ return this.#startResume(rec, message, descriptor);
1085
1147
  }
1086
1148
 
1087
1149
  #startResume(
1088
1150
  rec: SubagentRecord,
1089
- message?: string,
1151
+ message: string | undefined,
1152
+ descriptor: ResumeDescriptor | undefined,
1090
1153
  ): { ok: boolean; status?: SubagentLifecycle; jobId?: string; reason?: string } {
1091
1154
  if (this.#isOwnerSubagentShutdownFenced(rec.ownerId)) {
1092
1155
  return { ok: false, status: rec.status, reason: "owner_shutdown_in_progress" };
@@ -1095,13 +1158,14 @@ export class AsyncJobManager {
1095
1158
  // Clear any retained progress from the previous run so a resumed subagent
1096
1159
  // never renders the prior run's tool/output as live before it emits again.
1097
1160
  this.#subagentProgress.delete(rec.subagentId);
1098
- const runner = this.#resolveResumeRunner(rec.subagentId);
1099
- const newJobId = runner?.(rec.subagentId, message, this.#resumeDescriptors.get(rec.subagentId));
1161
+ const runner = this.#resolveResumeRunner(rec, descriptor);
1162
+ const newJobId = runner?.(rec.subagentId, message, descriptor);
1100
1163
  if (!newJobId) return { ok: false, reason: "resume_failed" };
1101
1164
  if (prevJobId && prevJobId !== newJobId) rec.historicalJobIds.push(prevJobId);
1102
1165
  rec.currentJobId = newJobId;
1103
1166
  rec.status = this.#jobs.get(newJobId)?.status ?? "running";
1104
1167
  rec.queued = undefined;
1168
+ this.#notifyChange();
1105
1169
  return { ok: true, status: rec.status, jobId: newJobId };
1106
1170
  }
1107
1171
 
@@ -1122,7 +1186,7 @@ export class AsyncJobManager {
1122
1186
  continue;
1123
1187
  }
1124
1188
  try {
1125
- const result = this.#startResume(rec, entry.message);
1189
+ const result = this.#startResume(rec, entry.message, this.#descriptorForRecord(rec));
1126
1190
  if (result.reason === "owner_shutdown_in_progress") {
1127
1191
  index += 1;
1128
1192
  continue;
@@ -1144,16 +1208,15 @@ export class AsyncJobManager {
1144
1208
  if (!rec) return false;
1145
1209
  if (rec.status === "running" && rec.currentJobId) return this.cancel(rec.currentJobId, filter);
1146
1210
  if (rec.status === "paused") {
1147
- if (rec.currentJobId) {
1148
- const job = this.#jobs.get(rec.currentJobId);
1149
- if (job && job.status === "paused") {
1150
- job.status = "cancelled";
1151
- this.#scheduleEviction(rec.currentJobId);
1152
- }
1153
- }
1211
+ const currentJobId = rec.currentJobId;
1212
+ const job = currentJobId ? this.#jobs.get(currentJobId) : undefined;
1213
+ const shouldScheduleEviction = job?.status === "paused";
1214
+ if (shouldScheduleEviction) job.status = "cancelled";
1154
1215
  rec.status = "cancelled";
1155
1216
  this.#liveHandles.delete(rec.subagentId);
1156
1217
  this.#subagentProgress.delete(rec.subagentId);
1218
+ if (shouldScheduleEviction && currentJobId) this.#scheduleEviction(currentJobId);
1219
+ else this.#notifyChange();
1157
1220
  this.#drainResumeQueue();
1158
1221
  return true;
1159
1222
  }
@@ -1163,6 +1226,7 @@ export class AsyncJobManager {
1163
1226
  rec.status = "cancelled";
1164
1227
  rec.queued = undefined;
1165
1228
  this.#subagentProgress.delete(rec.subagentId);
1229
+ this.#notifyChange();
1166
1230
  return true;
1167
1231
  }
1168
1232
  return false;
@@ -1413,11 +1477,7 @@ export class AsyncJobManager {
1413
1477
  */
1414
1478
  cancelAll(filter?: AsyncJobFilter): void {
1415
1479
  for (const job of this.getRunningJobs(filter)) {
1416
- this.#runLifecycle(job.id, "cancel");
1417
- job.status = "cancelled";
1418
- this.#freezeEndTime(job);
1419
- job.abortController.abort();
1420
- this.#scheduleEviction(job.id);
1480
+ if (this.cancel(job.id, filter)) this.#scheduleEviction(job.id);
1421
1481
  }
1422
1482
  }
1423
1483
 
@@ -93,7 +93,7 @@ Available Tools (default-enabled unless noted):
93
93
  bash - Execute bash commands
94
94
  edit - Edit files with find/replace
95
95
  write - Write files (creates/overwrites)
96
- grep - Search file contents
96
+ search - Search file contents
97
97
  find - Find files by glob pattern
98
98
  lsp - Language server protocol (code intelligence)
99
99
  python - Execute Python code (requires: ${APP_NAME} setup python)