@itpay/cli 2.0.3 → 2.0.7

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 (77) hide show
  1. package/README.md +96 -142
  2. package/dist/src/client/backend.js +26 -8
  3. package/dist/src/client/http.js +29 -23
  4. package/dist/src/commands/buy.js +84 -132
  5. package/dist/src/commands/cart.js +274 -169
  6. package/dist/src/commands/catalog.js +64 -38
  7. package/dist/src/commands/checkout.js +128 -79
  8. package/dist/src/commands/docs.js +97 -51
  9. package/dist/src/commands/guidance.js +112 -16
  10. package/dist/src/commands/install.js +50 -87
  11. package/dist/src/commands/next.js +45 -0
  12. package/dist/src/commands/order.js +44 -69
  13. package/dist/src/commands/orders.js +43 -15
  14. package/dist/src/commands/pay.js +51 -22
  15. package/dist/src/commands/readyz.js +8 -4
  16. package/dist/src/commands/refund.js +132 -11
  17. package/dist/src/commands/services.js +799 -147
  18. package/dist/src/commands/skill.js +55 -0
  19. package/dist/src/main.js +820 -193
  20. package/dist/src/render/output.js +2 -3
  21. package/dist/src/state/agent_type.js +19 -0
  22. package/dist/src/state/cart_session.js +13 -17
  23. package/dist/src/state/client_context.js +4 -2
  24. package/dist/src/state/config.js +5 -15
  25. package/dist/src/state/device_authority.js +175 -57
  26. package/docs/agent/buyer/cart-checkout.json +27 -83
  27. package/docs/agent/buyer/catalog-list.json +2 -1
  28. package/docs/agent/buyer/identity-and-sessions.json +64 -0
  29. package/docs/agent/buyer/install-and-setup.json +35 -65
  30. package/docs/agent/buyer/orders-refunds.json +31 -53
  31. package/docs/agent/buyer/payment-flow.json +28 -57
  32. package/docs/agent/buyer/quickstart.json +46 -161
  33. package/docs/agent/buyer/render-hosts.json +43 -57
  34. package/docs/cli-reference/agent-types.md +51 -0
  35. package/docs/cli-reference/commands/buy.md +167 -0
  36. package/docs/cli-reference/commands/cart/add.md +86 -0
  37. package/docs/cli-reference/commands/cart/clear.md +53 -0
  38. package/docs/cli-reference/commands/cart/index.md +30 -0
  39. package/docs/cli-reference/commands/cart/next.md +71 -0
  40. package/docs/cli-reference/commands/cart/remove.md +53 -0
  41. package/docs/cli-reference/commands/cart/show.md +65 -0
  42. package/docs/cli-reference/commands/catalog/index.md +26 -0
  43. package/docs/cli-reference/commands/catalog/list.md +45 -0
  44. package/docs/cli-reference/commands/checkout.md +74 -0
  45. package/docs/cli-reference/commands/device.md +13 -0
  46. package/docs/cli-reference/commands/docs/index.md +28 -0
  47. package/docs/cli-reference/commands/docs/list.md +51 -0
  48. package/docs/cli-reference/commands/docs/search.md +69 -0
  49. package/docs/cli-reference/commands/docs/show.md +68 -0
  50. package/docs/cli-reference/commands/install.md +114 -0
  51. package/docs/cli-reference/commands/next.md +87 -0
  52. package/docs/cli-reference/commands/order.md +92 -0
  53. package/docs/cli-reference/commands/orders.md +83 -0
  54. package/docs/cli-reference/commands/pay.md +103 -0
  55. package/docs/cli-reference/commands/readyz.md +38 -0
  56. package/docs/cli-reference/commands/refund/cancel.md +62 -0
  57. package/docs/cli-reference/commands/refund/create.md +85 -0
  58. package/docs/cli-reference/commands/refund/get.md +60 -0
  59. package/docs/cli-reference/commands/refund/index.md +33 -0
  60. package/docs/cli-reference/commands/refund/list.md +68 -0
  61. package/docs/cli-reference/commands/refund/watch.md +73 -0
  62. package/docs/cli-reference/commands/services/action.md +48 -0
  63. package/docs/cli-reference/commands/services/checkout.md +82 -0
  64. package/docs/cli-reference/commands/services/events.md +73 -0
  65. package/docs/cli-reference/commands/services/get.md +66 -0
  66. package/docs/cli-reference/commands/services/index.md +45 -0
  67. package/docs/cli-reference/commands/services/invoke.md +67 -0
  68. package/docs/cli-reference/commands/services/list.md +61 -0
  69. package/docs/cli-reference/commands/services/next.md +181 -0
  70. package/docs/cli-reference/commands/services/quote.md +63 -0
  71. package/docs/cli-reference/commands/services/read-result.md +98 -0
  72. package/docs/cli-reference/commands/services/start.md +55 -0
  73. package/docs/cli-reference/commands/skill.md +17 -0
  74. package/docs/cli-reference/conventions.md +97 -0
  75. package/docs/cli-reference/index.md +65 -0
  76. package/package.json +1 -1
  77. package/skills/itpay-buyer/SKILL.md +71 -110
@@ -1,52 +1,259 @@
1
1
  import { operationID } from "../state/config.js";
2
- import { resolveOutput } from "../render/sink.js";
3
2
  import { dispatchRender } from "../render/index.js";
4
3
  import { ensureIdeImageAttach } from "../render/ide.js";
5
4
  import { buildAgentChatHandoff } from "../render/markdown.js";
5
+ import { platformKeyForHost } from "../render/plan.js";
6
+ import { renderTerminalQR } from "../render/qr.js";
6
7
  import { buildCheckoutQRPlan } from "./buy.js";
7
- import { attachAgentGuidance, buildServiceActionGuidance, buildServiceInvokedGuidance, buildServiceReadModelGuidance, buildServiceStartedGuidance, printAgentGuidance, } from "./guidance.js";
8
+ import { CommandContractError, isTerminalServiceExecutionStatus, writeCommandEnvelope, } from "./guidance.js";
8
9
  const serviceActionStatuses = new Set(["pending", "approved", "rejected", "expired", "cancelled"]);
9
- export async function runServicesStart(backend, config, serviceID, options = {}) {
10
+ export async function runServicesStart(backend, serviceID, options = {}) {
10
11
  const host = options.host ?? "terminal";
11
12
  const response = await backend.startServiceExecution({
12
13
  service_id: serviceID,
13
- agent_device_id: config.agentDeviceID,
14
- ...(options.buyerID ? { buyer_id: options.buyerID } : {}),
15
14
  client_context: {
16
- agent_device_id: config.agentDeviceID,
17
15
  host,
18
16
  ...(options.target ? { target: options.target } : {}),
19
17
  ...(options.clientContext ?? {}),
20
18
  },
21
19
  });
22
- writeJSON(options.output, attachAgentGuidance(response, buildServiceStartedGuidance(response)));
20
+ const capability = response.capabilities.find((item) => item.phase === response.execution.phase && !item.requires_payment);
21
+ const requiredInput = requiredInputFields(capability?.input_schema);
22
+ const command = capability
23
+ ? `itpay services invoke ${response.execution.service_execution_id} --capability ${capability.capability_id}${requiredInput.map((field) => ` --input ${field}=<value>`).join("")} --json`
24
+ : `itpay services next ${response.execution.service_execution_id} --json`;
25
+ const capabilitySummary = capability ? {
26
+ capability_id: capability.capability_id,
27
+ required_input: requiredInput,
28
+ ...(capability.free_quota_limit !== undefined ? { free_quota_limit: capability.free_quota_limit } : {}),
29
+ } : null;
30
+ writeCommandEnvelope({
31
+ status: "ready",
32
+ result: {
33
+ service_execution_id: response.execution.service_execution_id,
34
+ service_id: response.execution.service_id,
35
+ phase: response.execution.phase,
36
+ capability: capabilitySummary,
37
+ },
38
+ instruction: capability
39
+ ? "填写首选 capability 的 required_input;一次只提交当前 execution 所代表的服务意图。"
40
+ : "当前没有可直接调用的 capability;读取服务端下一步,不要猜测 capability。",
41
+ next: {
42
+ command,
43
+ reason: capability ? "执行当前允许的能力" : "读取服务端计算的下一步",
44
+ },
45
+ recovery: [],
46
+ }, {
47
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
48
+ ...(options.output ? { output: options.output } : {}),
49
+ plainResult: [
50
+ `service_execution_id: ${response.execution.service_execution_id}`,
51
+ `service_id: ${response.execution.service_id}`,
52
+ `phase: ${response.execution.phase}`,
53
+ ...(capability ? [
54
+ `capability: ${capability.capability_id}`,
55
+ `required_input: ${requiredInput.length > 0 ? requiredInput.join(",") : "none"}`,
56
+ ...(capability.free_quota_limit !== undefined ? [`free_quota_limit: ${capability.free_quota_limit}`] : []),
57
+ ] : []),
58
+ ],
59
+ });
60
+ }
61
+ function requiredInputFields(schema) {
62
+ const required = schema?.required;
63
+ return Array.isArray(required) ? required.filter((field) => typeof field === "string") : [];
23
64
  }
24
65
  export async function runServicesInvoke(backend, config, serviceExecutionID, capabilityID, input, options = {}) {
66
+ const readModel = await backend.getServiceExecution(serviceExecutionID);
67
+ const requestedCapability = readModel.capabilities.find((capability) => capability.capability_id === capabilityID);
68
+ if (!requestedCapability) {
69
+ throw new CommandContractError("capability_not_found", `capability ${capabilityID} is not available on service execution ${serviceExecutionID}`, "使用 Service Execution 当前返回的 capability_id,不要猜测名称。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前可用 capability" }]);
70
+ }
71
+ if (requestedCapability.requires_payment) {
72
+ throw new CommandContractError("checkout_required", `capability ${capabilityID} requires checkout and cannot be invoked directly`, "付费 capability 不能直接 invoke。不要尝试 quote、cart、buy、checkout 或 pay 作为旁路;只恢复同一 Execution 的当前合法动作。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取同一 Execution 的当前合法动作" }]);
73
+ }
74
+ const missingInput = missingRequiredInput(requestedCapability.input_schema, input);
75
+ if (missingInput.length > 0) {
76
+ const correctedInput = { ...input };
77
+ for (const field of missingInput)
78
+ correctedInput[field] = "<value>";
79
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐 required_input 后重试同一个 execution;本次没有调用 Provider。", [{
80
+ command: `itpay services invoke ${serviceExecutionID} --capability ${capabilityID}${formatInputOptions(correctedInput)} --json`,
81
+ reason: "提交完整 capability 输入",
82
+ }]);
83
+ }
25
84
  const idempotencyKey = await operationID(config, `service.invoke:${serviceExecutionID}:${capabilityID}:${stableInput(input)}`);
26
85
  const response = await backend.invokeServiceCapability(serviceExecutionID, capabilityID, {
27
86
  idempotency_key: idempotencyKey,
28
87
  redacted_summary: input,
29
88
  });
30
- let capabilities = [];
31
- if (response.next_actions?.some((action) => action.kind === "create_checkout")) {
32
- try {
33
- capabilities = (await backend.getServiceExecution(serviceExecutionID)).capabilities;
89
+ const envelope = invokedEnvelope(response, requestedCapability, readModel.capabilities, input);
90
+ writeCommandEnvelope(envelope.value, {
91
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
92
+ ...(options.output ? { output: options.output } : {}),
93
+ plainResult: envelope.plainResult,
94
+ });
95
+ }
96
+ function invokedEnvelope(response, requestedCapability, capabilities, input) {
97
+ const items = response.result_items.map((item) => ({
98
+ rank: item.rank,
99
+ title: item.display_title,
100
+ safe_payload: item.safe_payload,
101
+ }));
102
+ const quota = response.effective_quota
103
+ ? { remaining: response.effective_quota.remaining, limit: response.effective_quota.limit }
104
+ : undefined;
105
+ const baseResult = {
106
+ service_execution_id: response.execution.service_execution_id,
107
+ capability_id: requestedCapability.capability_id,
108
+ items,
109
+ ...(quota ? { quota } : {}),
110
+ };
111
+ let status = items.length > 0 ? "result_ready" : "no_result";
112
+ let instruction = items.length > 0
113
+ ? "向用户展示编号和 safe_payload;若候选列表已满足用户目标,在此停止。仅在用户明确选择并希望继续时,才在当前 Execution 提交对应 rank。"
114
+ : "Provider 已返回空结果;不要重放当前 execution,按下一步恢复。";
115
+ let next = null;
116
+ if (response.effective_quota?.exhausted) {
117
+ status = "quota_exhausted";
118
+ instruction = "免费额度已用完且本次没有调用 Provider。当前没有可购买的 continuation;只读取同一 Execution 的服务端恢复方向。";
119
+ const checkoutAction = response.next_actions?.find((action) => action.kind === "create_checkout");
120
+ const checkoutCapability = capabilities.find((capability) => capability.capability_id === checkoutAction?.capability_id);
121
+ if (checkoutCapability) {
122
+ baseResult.checkout = {
123
+ capability_id: checkoutCapability.capability_id,
124
+ ...(checkoutCapability.price_amount_minor !== undefined && checkoutCapability.price_currency ? {
125
+ price: { amount_minor: checkoutCapability.price_amount_minor, currency: checkoutCapability.price_currency },
126
+ } : {}),
127
+ delivery_email_required: checkoutCapability.delivery_email_required,
128
+ };
129
+ const price = capabilityPrice(checkoutCapability);
130
+ instruction = purchaseConfirmationInstruction("quota_exhausted", price, checkoutCapability.delivery_email_required);
131
+ next = {
132
+ command: checkoutCommand(response.execution.service_execution_id, checkoutCapability, input),
133
+ reason: `仅在用户明确同意支付 ${price} 后执行;否则停止`,
134
+ };
34
135
  }
35
- catch {
36
- // Preserve the successful invocation response. Checkout performs the same
37
- // capability-aware email validation before creating a handoff.
136
+ else {
137
+ next = {
138
+ command: `itpay services next ${response.execution.service_execution_id} --json`,
139
+ reason: "读取服务端提供的付费恢复入口",
140
+ };
38
141
  }
39
142
  }
40
- const guidance = buildServiceInvokedGuidance(response, capabilities);
41
- if (options.jsonOutput) {
42
- writeJSON(options.output, attachAgentGuidance(response, guidance));
43
- return;
143
+ else if (items.length > 0 && requestedCapability.requires_human_action) {
144
+ next = {
145
+ command: `itpay services action ${response.execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
146
+ reason: "在当前 Execution 记录用户选择",
147
+ };
44
148
  }
45
- printAgentGuidance(guidance, options.output);
149
+ else if (items.length === 0) {
150
+ next = response.provider_called
151
+ ? { command: `itpay services start ${response.execution.service_id}`, reason: "为新的服务输入启动新 execution" }
152
+ : { command: `itpay services next ${response.execution.service_execution_id} --json`, reason: "读取服务端恢复动作" };
153
+ }
154
+ return {
155
+ value: { status, result: baseResult, instruction, next, recovery: [] },
156
+ plainResult: serviceResultPlainLines(baseResult),
157
+ };
158
+ }
159
+ function serviceResultPlainLines(result) {
160
+ const lines = [
161
+ `service_execution_id: ${String(result.service_execution_id)}`,
162
+ `capability_id: ${String(result.capability_id)}`,
163
+ ];
164
+ if (result.quota)
165
+ lines.push(`quota: ${JSON.stringify(result.quota)}`);
166
+ if (result.checkout)
167
+ lines.push(`checkout: ${JSON.stringify(result.checkout)}`);
168
+ const items = result.items;
169
+ if (items.length > 0) {
170
+ lines.push("items:");
171
+ for (const item of items) {
172
+ lines.push(` ${item.rank}. ${item.title}`);
173
+ for (const [key, value] of Object.entries(item.safe_payload)) {
174
+ lines.push(` ${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
175
+ }
176
+ }
177
+ }
178
+ return lines;
179
+ }
180
+ function missingRequiredInput(schema, input) {
181
+ return requiredInputFields(schema).filter((field) => {
182
+ if (!(field in input) || input[field] === null || input[field] === undefined)
183
+ return true;
184
+ return typeof input[field] === "string" && String(input[field]).trim() === "";
185
+ });
186
+ }
187
+ function checkoutCommand(serviceExecutionID, capability, input, fillMissing = true) {
188
+ const lockedInput = { ...input };
189
+ if (fillMissing) {
190
+ for (const field of missingRequiredInput(capability.input_schema, lockedInput))
191
+ lockedInput[field] = "<value>";
192
+ }
193
+ return `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)}${capability.delivery_email_required ? " --email <email>" : ""} --json`;
194
+ }
195
+ function capabilityPrice(capability) {
196
+ return capability.price_amount_minor !== undefined && capability.price_currency
197
+ ? formatMoney(capability.price_amount_minor, capability.price_currency)
198
+ : "当前发布价格";
199
+ }
200
+ function purchaseConfirmationInstruction(context, price, deliveryEmailRequired, candidateTitle = "") {
201
+ if (context === "quota_exhausted") {
202
+ return deliveryEmailRequired
203
+ ? `免费额度已用完,本次没有调用 Provider,也尚未创建 Quote 或 Checkout。现在只向用户说明:继续当前请求需要支付 ${price},交付还需要用户邮箱;请确认是否购买并提供邮箱。然后停止并等待。用户明确同意并提供真实邮箱前,不要执行 next.command,不要新建 Execution,不要尝试其他 capability、quote、cart、buy、checkout 或 pay 命令。`
204
+ : `免费额度已用完,本次没有调用 Provider,也尚未创建 Quote 或 Checkout。现在只向用户说明:“继续当前请求需要支付 ${price},是否购买?”然后停止并等待用户明确回复。用户明确同意前,不要执行 next.command,不要新建 Execution,不要尝试其他 capability、quote、cart、buy、checkout 或 pay 命令。`;
205
+ }
206
+ const selected = candidateTitle ? `已选择 ${candidateTitle}。` : "当前候选已经确认。";
207
+ return deliveryEmailRequired
208
+ ? `${selected}候选已绑定到当前 Execution,但尚未购买后续服务。现在只向用户说明:继续购买后续服务需要支付 ${price},并提供用于发送交付认领链接的邮箱;请确认是否购买并提供邮箱。然后停止。用户明确同意并提供真实邮箱前,不要执行 next.command,不要创建新 Execution 或 Checkout。`
209
+ : `${selected}候选已绑定到当前 Execution,但尚未购买后续服务。现在只向用户说明:“继续购买后续服务需要支付 ${price},是否购买?”然后停止。用户明确同意前,不要执行 next.command,不要创建新 Execution 或 Checkout。`;
210
+ }
211
+ function paidContinuation(model, action, input) {
212
+ if (!action.capability_id)
213
+ return null;
214
+ const capability = model.capabilities.find((item) => item.capability_id === action.capability_id && item.requires_payment);
215
+ if (!capability)
216
+ return null;
217
+ const price = capabilityPrice(capability);
218
+ const stateBacked = model.execution.status === "quota_exhausted" || model.execution.status === "human_action_approved";
219
+ return {
220
+ capability,
221
+ price,
222
+ checkout: {
223
+ capability_id: capability.capability_id,
224
+ ...(capability.price_amount_minor !== undefined && capability.price_currency ? {
225
+ price: { amount_minor: capability.price_amount_minor, currency: capability.price_currency },
226
+ } : {}),
227
+ delivery_email_required: capability.delivery_email_required,
228
+ },
229
+ next: {
230
+ command: checkoutCommand(model.execution.service_execution_id, capability, input, !stateBacked),
231
+ reason: `仅在用户明确同意支付 ${price}${capability.delivery_email_required ? " 并提供真实邮箱" : ""}后执行;否则停止`,
232
+ },
233
+ };
234
+ }
235
+ function quoteCommand(serviceExecutionID, capability, input) {
236
+ const lockedInput = { ...input };
237
+ for (const field of missingRequiredInput(capability.input_schema, lockedInput))
238
+ lockedInput[field] = "<value>";
239
+ return `itpay services quote ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)}${capability.delivery_email_required ? " --email <email>" : ""} --json`;
46
240
  }
47
241
  function stableInput(input) {
48
242
  return JSON.stringify(Object.fromEntries(Object.entries(input).sort(([left], [right]) => left.localeCompare(right))));
49
243
  }
244
+ function formatInputOptions(input) {
245
+ return Object.entries(input)
246
+ .sort(([left], [right]) => left.localeCompare(right))
247
+ .map(([key, value]) => String(value) === "<value>"
248
+ ? ` --input ${key}=<value>`
249
+ : ` --input ${shellArgument(`${key}=${String(value)}`)}`)
250
+ .join("");
251
+ }
252
+ function shellArgument(value) {
253
+ if (/^[\p{L}\p{N}._:=/-]+$/u.test(value))
254
+ return value;
255
+ return `'${value.replaceAll("'", `'"'"'`)}'`;
256
+ }
50
257
  export async function runServicesAction(backend, serviceExecutionID, actionType, input, options = {}) {
51
258
  const selection = await resolveCandidateSelection(backend, serviceExecutionID, actionType, options);
52
259
  const request = {
@@ -58,61 +265,118 @@ export async function runServicesAction(backend, serviceExecutionID, actionType,
58
265
  if (options.actorID)
59
266
  request.actor_id = options.actorID;
60
267
  if (options.status)
61
- request.status = normalizeServiceActionStatus(options.status);
268
+ request.status = normalizeServiceActionStatus(options.status, serviceExecutionID);
62
269
  const resultItemID = selection?.resultItemID ?? options.resultItemID;
63
- const selectedCandidateHash = selection?.stableHash ?? options.selectedCandidateHash;
64
270
  if (resultItemID)
65
271
  request.result_item_id = resultItemID;
66
- if (selectedCandidateHash)
67
- request.selected_candidate_hash = selectedCandidateHash;
68
272
  if (options.requiredBefore)
69
273
  request.required_before = options.requiredBefore;
70
274
  const response = await backend.recordServiceExecutionAction(serviceExecutionID, request);
71
- writeJSON(options.output, attachAgentGuidance(response, buildServiceActionGuidance(response)));
275
+ if (selection && actionType === "select_candidate" && response.status === "approved") {
276
+ const updated = await backend.getServiceExecution(serviceExecutionID);
277
+ const preferred = updated.allowed_actions?.[0];
278
+ const continuation = preferred?.type === "prepare_quote"
279
+ ? paidContinuation(updated, preferred, {})
280
+ : null;
281
+ const next = continuation?.next ?? (preferred ? serviceAllowedActionCommand(updated, preferred) : null);
282
+ writeCommandEnvelope({
283
+ status: "candidate_selected",
284
+ result: {
285
+ service_execution_id: response.service_execution_id,
286
+ candidate: { rank: selection.rank, title: selection.title },
287
+ ...(continuation ? { checkout: continuation.checkout } : {}),
288
+ },
289
+ instruction: continuation
290
+ ? purchaseConfirmationInstruction("candidate_selected", continuation.price, continuation.capability.delivery_email_required, selection.title)
291
+ : "候选已绑定到来源 Execution;后续动作必须继续使用该 Execution。",
292
+ next,
293
+ recovery: [{
294
+ command: `itpay services next ${response.service_execution_id} --json`,
295
+ reason: "重新读取服务端允许的动作",
296
+ }],
297
+ }, {
298
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
299
+ ...(options.output ? { output: options.output } : {}),
300
+ });
301
+ return;
302
+ }
303
+ writeCommandEnvelope({
304
+ status: "action_recorded",
305
+ result: {
306
+ service_execution_id: response.service_execution_id,
307
+ action_type: response.action_type,
308
+ action_status: response.status,
309
+ },
310
+ instruction: "动作已记录,读取服务端计算的新状态;不要自行假设下一 capability。",
311
+ next: {
312
+ command: `itpay services next ${response.service_execution_id} --json`,
313
+ reason: "取得更新后的首选动作",
314
+ },
315
+ recovery: [],
316
+ }, {
317
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
318
+ ...(options.output ? { output: options.output } : {}),
319
+ });
72
320
  }
73
321
  async function resolveCandidateSelection(backend, serviceExecutionID, actionType, options) {
74
322
  if (options.candidateRank === undefined)
75
323
  return undefined;
76
324
  if (actionType !== "select_candidate") {
77
- throw new Error("--candidate is only valid with --action select_candidate");
325
+ throw actionInputError(serviceExecutionID, "--candidate is only valid with --action select_candidate");
78
326
  }
79
- if (options.resultItemID || options.selectedCandidateHash) {
80
- throw new Error("--candidate cannot be combined with --result-item or --selected-candidate-hash");
327
+ if (options.resultItemID) {
328
+ throw actionInputError(serviceExecutionID, "--candidate cannot be combined with --result-item");
81
329
  }
82
330
  if (!Number.isInteger(options.candidateRank) || options.candidateRank < 1) {
83
- throw new Error("--candidate must be a positive integer result rank");
331
+ throw actionInputError(serviceExecutionID, "--candidate must be a positive integer result rank");
84
332
  }
85
333
  const execution = await backend.getServiceExecution(serviceExecutionID);
86
- const result = execution.result_items.find((item) => item.rank === options.candidateRank);
334
+ const currentItems = execution.current_result_items ?? [];
335
+ const result = currentItems.find((item) => item.rank === options.candidateRank);
87
336
  if (!result) {
88
- throw new Error(`candidate ${options.candidateRank} is not available on service execution ${serviceExecutionID}`);
337
+ throw actionInputError(serviceExecutionID, `candidate ${options.candidateRank} is not available on service execution ${serviceExecutionID}`, "candidate_not_found");
89
338
  }
90
339
  return {
91
340
  resultItemID: result.service_capability_result_item_id,
92
- stableHash: result.stable_hash,
341
+ rank: result.rank,
342
+ title: result.display_title,
93
343
  };
94
344
  }
345
+ function actionInputError(serviceExecutionID, message, code = "service_action_invalid") {
346
+ return new CommandContractError(code, message, code === "candidate_not_found"
347
+ ? "当前 rank 不存在或当前候选集不可用。不要新建 Execution,不要重新 invoke,不要构造候选 ID;只恢复同一 Execution 当前仍然有效的候选。"
348
+ : "使用当前 safe result 中的合法 action 和 candidate rank;需要人确认时先询问用户。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "重新读取同一 Execution 的当前可选动作" }]);
349
+ }
95
350
  export async function runServicesCheckout(backend, config, serviceExecutionID, capabilityID, options = {}) {
96
351
  const deliveryContact = {
97
352
  ...(options.deliveryContact ?? {}),
98
353
  ...(options.email ? { email: options.email } : {}),
99
354
  };
100
355
  if (!options.resume && !capabilityID) {
101
- throw new Error("--capability is required when creating a service checkout; use --resume to recover an existing handoff");
356
+ throw new CommandContractError("capability_required", "--capability is required when creating a service checkout", "使用当前 Service Execution 返回的付费 capability;恢复已有 Checkout 时改用 --resume。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前允许的付费 capability" }]);
102
357
  }
103
- if (!options.resume && String(deliveryContact.email ?? "").trim() === "") {
104
- const execution = await backend.getServiceExecution(serviceExecutionID);
105
- const capability = execution.capabilities.find((item) => item.capability_id === capabilityID);
106
- if (!capability) {
107
- throw new Error(`capability ${capabilityID} is not available on service execution ${serviceExecutionID}`);
358
+ if (!options.resume) {
359
+ const readModel = await backend.getServiceExecution(serviceExecutionID);
360
+ const capability = readModel.capabilities.find((item) => item.capability_id === capabilityID);
361
+ if (!capability || !capability.requires_payment) {
362
+ throw new CommandContractError("capability_not_checkoutable", `capability ${capabilityID} is not available for checkout on service execution ${serviceExecutionID}`, "只为当前 Service Execution 返回的 requires_payment capability 创建 Checkout。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前允许的下一步" }]);
363
+ }
364
+ const lockedInput = options.lockedInput ?? {};
365
+ const missingInput = missingRequiredInput(capability.input_schema, lockedInput);
366
+ if (missingInput.length > 0 && readModel.execution.next_action !== "create_checkout") {
367
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 的 required_input;本次没有创建 quote、Checkout 或订单。", [{ command: checkoutCommand(serviceExecutionID, capability, lockedInput), reason: "提交完整且会被锁定的服务输入" }]);
108
368
  }
109
- if (capability.delivery_email_required) {
110
- throw new Error("delivery email is required before creating this service checkout; ask the buyer for --email");
369
+ if (capability.delivery_email_required && String(deliveryContact.email ?? "").trim() === "") {
370
+ throw new CommandContractError("delivery_email_required", "delivery email is required before creating this service checkout", "该 capability 的交付链接会发送到用户邮箱;先向用户说明用途并询问邮箱,不要代填。", [{
371
+ command: `itpay services checkout ${serviceExecutionID} --capability ${capability.capability_id}${formatInputOptions(lockedInput)} --email <email> --json`,
372
+ reason: "使用用户提供的邮箱创建 Checkout",
373
+ }]);
111
374
  }
112
375
  }
113
376
  const response = await backend.createServiceExecutionCheckout(serviceExecutionID, {
114
377
  ...(capabilityID ? { capability_id: capabilityID } : {}),
115
378
  ...(Object.keys(deliveryContact).length > 0 ? { delivery_contact: deliveryContact } : {}),
379
+ ...(options.lockedInput && Object.keys(options.lockedInput).length > 0 ? { locked_input: options.lockedInput } : {}),
116
380
  ...(options.resume ? { resume: true } : {}),
117
381
  });
118
382
  const checkout = response.checkout;
@@ -142,67 +406,473 @@ export async function runServicesCheckout(backend, config, serviceExecutionID, c
142
406
  displayToken,
143
407
  checkoutURL,
144
408
  });
145
- if (options.jsonOutput) {
146
- await ensureIdeImageAttach(plan, {
147
- enabled: config.ideImageAttach,
148
- ...(config.baseURL ? { baseURL: config.baseURL } : {}),
409
+ const platform = platformKeyForHost(plan.host);
410
+ if (platform === "telegram" || platform === "feishu" || platform === "lark") {
411
+ await dispatchRender(plan, {
412
+ host: options.host ?? "terminal",
413
+ ...(options.target ? { target: options.target } : {}),
414
+ ...(options.qrFormat ? { qrFormat: options.qrFormat } : {}),
415
+ ...(options.qrFilePath ? { qrFilePath: options.qrFilePath } : {}),
416
+ ...(options.output ? { output: options.output } : {}),
149
417
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
418
+ baseURL: config.baseURL,
150
419
  });
151
- writeJSON(options.output, buildServicesCheckoutJSON(response, checkoutURL, plan));
152
420
  return;
153
421
  }
154
- await dispatchRender(plan, {
155
- host: options.host ?? "terminal",
156
- isTTY: options.isTTY ?? Boolean(process.stdout.isTTY),
157
- ...(options.target ? { target: options.target } : {}),
158
- ...(options.qrFormat ? { qrFormat: options.qrFormat } : {}),
159
- ...(options.qrFilePath ? { qrFilePath: options.qrFilePath } : {}),
160
- ...(options.output ? { output: options.output } : {}),
422
+ await ensureIdeImageAttach(plan, {
423
+ enabled: config.ideImageAttach,
424
+ ...(config.baseURL ? { baseURL: config.baseURL } : {}),
161
425
  ...(options.fetchImpl ? { fetchImpl: options.fetchImpl } : {}),
162
- baseURL: config.baseURL,
426
+ });
427
+ const envelope = buildServicesCheckoutEnvelope(response, checkoutURL, plan, config.baseURL);
428
+ const plainResult = [
429
+ `service_execution_id: ${response.binding.service_execution_id}`,
430
+ `checkout_id: ${checkoutID}`,
431
+ `capability_id: ${checkoutCapabilityID(response, capabilityID)}`,
432
+ `locked_input: ${JSON.stringify(response.locked_input)}`,
433
+ `amount: ${formatMoney(checkout.checkout.amount_minor, checkout.checkout.currency)}`,
434
+ ];
435
+ if (!options.jsonOutput && platform === "terminal") {
436
+ plainResult.push("qr:", await renderTerminalQR(checkoutURL, options.qrFormat ?? "terminal"));
437
+ }
438
+ writeCommandEnvelope(envelope, {
439
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
440
+ ...(options.output ? { output: options.output } : {}),
441
+ plainResult,
442
+ });
443
+ }
444
+ export async function runServicesQuote(backend, serviceExecutionID, capabilityID, input, options = {}) {
445
+ const model = await backend.getServiceExecution(serviceExecutionID);
446
+ const capability = model.capabilities.find((item) => item.capability_id === capabilityID);
447
+ if (!capability || !capability.requires_payment) {
448
+ throw new CommandContractError("capability_not_quoteable", `capability ${capabilityID} is not available for quote on service execution ${serviceExecutionID}`, "只为当前 Service Execution 返回的付费 capability 创建报价。", [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "读取当前合法动作" }]);
449
+ }
450
+ const selectionBacked = model.execution.status === "human_action_approved" &&
451
+ model.allowed_actions?.some((action) => action.type === "prepare_quote" && action.capability_id === capabilityID);
452
+ const missingInput = missingRequiredInput(capability.input_schema, input);
453
+ if (missingInput.length > 0 && !selectionBacked) {
454
+ throw new CommandContractError("capability_input_invalid", `missing required capability input: ${missingInput.join(", ")}`, "补齐付费 capability 输入;本次没有创建 Quote、Cart 或 Checkout。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "提交完整且会被锁定的输入" }]);
455
+ }
456
+ const deliveryContact = {
457
+ ...(options.deliveryContact ?? {}),
458
+ ...(options.email ? { email: options.email } : {}),
459
+ };
460
+ if (capability.delivery_email_required && String(deliveryContact.email ?? "").trim() === "") {
461
+ throw new CommandContractError("delivery_email_required", "delivery email is required before preparing this service quote", "交付链接会发送到用户邮箱;说明用途并询问邮箱,不要代填。", [{ command: quoteCommand(serviceExecutionID, capability, input), reason: "使用用户提供的邮箱创建报价" }]);
462
+ }
463
+ const quote = await backend.prepareServiceQuote(serviceExecutionID, {
464
+ capability_id: capabilityID,
465
+ ...(Object.keys(deliveryContact).length > 0 ? { delivery_contact: deliveryContact } : {}),
466
+ ...(Object.keys(input).length > 0 ? { locked_input: input } : {}),
467
+ });
468
+ const result = {
469
+ service_quote_lock_id: quote.service_quote_lock_id,
470
+ service_execution_id: quote.service_execution_id,
471
+ capability_id: quote.capability_id,
472
+ price: formatMoney(quote.amount_minor, quote.currency),
473
+ expires_at: quote.expires_at,
474
+ };
475
+ writeCommandEnvelope({
476
+ status: "quote_ready",
477
+ result,
478
+ instruction: "报价已锁定当前 Execution 的可信输入和价格;可单独付款,也可与其他独立 Execution 的报价合并。",
479
+ next: {
480
+ command: `itpay cart add --quote ${quote.service_quote_lock_id} --json`,
481
+ reason: "加入 canonical Cart",
482
+ },
483
+ recovery: [{ command: `itpay services next ${serviceExecutionID} --json`, reason: "重新读取当前 Execution 状态" }],
484
+ }, {
485
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
486
+ ...(options.output ? { output: options.output } : {}),
487
+ plainResult: Object.entries(result).map(([key, value]) => `${key}: ${String(value)}`),
163
488
  });
164
489
  }
165
490
  export async function runServicesGet(backend, serviceExecutionID, options = {}) {
166
491
  const response = await backend.getServiceExecution(serviceExecutionID);
167
- const guidance = buildServiceReadModelGuidance(response);
168
- if (options.jsonOutput) {
169
- writeJSON(options.output, attachAgentGuidance(response, guidance));
170
- return;
171
- }
172
- printAgentGuidance(guidance, options.output);
492
+ const execution = response.execution;
493
+ const timeline = response.events.slice(-20).map((event) => ({
494
+ sequence: event.sequence,
495
+ step: event.type,
496
+ status: event.status,
497
+ phase: event.phase,
498
+ ...(event.capability_id ? { capability_id: event.capability_id } : {}),
499
+ occurred_at: event.occurred_at,
500
+ }));
501
+ const deliveryMode = serviceDeliveryMode(response);
502
+ const lockedRefund = response.refunds.find((refund) => refund.access_locked);
503
+ const nextState = servicesNextEnvelope(response);
504
+ const result = {
505
+ service_execution_id: execution.service_execution_id,
506
+ service_id: execution.service_id,
507
+ status: execution.status,
508
+ phase: execution.phase,
509
+ ...(execution.current_capability_id ? { current_capability_id: execution.current_capability_id } : {}),
510
+ updated_at: execution.updated_at,
511
+ timeline,
512
+ ...(response.events.length > timeline.length ? { timeline_truncated: true } : {}),
513
+ ...(deliveryMode ? { delivery_mode: deliveryMode } : {}),
514
+ ...(lockedRefund ? {
515
+ access_locked: true,
516
+ refund: { refund_request_id: lockedRefund.refund_request_id, status: lockedRefund.status },
517
+ } : {}),
518
+ };
519
+ const envelope = {
520
+ status: "shown",
521
+ result,
522
+ instruction: lockedRefund || isTerminalServiceExecutionStatus(execution.status)
523
+ ? nextState.instruction
524
+ : "时间线仅用于解释和恢复;按当前首选动作继续,不要重放已完成步骤。",
525
+ next: nextState.next ? { command: nextState.next.command, reason: "继续当前首选动作" } : null,
526
+ recovery: [{ command: `itpay services events ${serviceExecutionID} --json`, reason: "仅在需要完整诊断事件时使用" }],
527
+ };
528
+ writeCommandEnvelope(envelope, {
529
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
530
+ ...(options.output ? { output: options.output } : {}),
531
+ plainResult: [
532
+ `service_execution_id: ${execution.service_execution_id}`,
533
+ `service_id: ${execution.service_id}`,
534
+ `state: ${execution.status}/${execution.phase}`,
535
+ ...(execution.current_capability_id ? [`current_capability_id: ${execution.current_capability_id}`] : []),
536
+ ...timeline.map((event) => `${event.sequence}. ${event.step} ${event.status}/${event.phase} ${event.occurred_at}`),
537
+ ],
538
+ });
173
539
  }
174
540
  export async function runServicesNext(backend, serviceExecutionID, options = {}) {
175
541
  const response = await backend.getServiceExecution(serviceExecutionID);
176
- const guidance = buildServiceReadModelGuidance(response);
177
- if (options.jsonOutput) {
178
- writeJSON(options.output, guidance);
179
- return;
180
- }
181
- printAgentGuidance(guidance, options.output);
542
+ const envelope = servicesNextEnvelope(response);
543
+ writeCommandEnvelope(envelope, {
544
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
545
+ ...(options.output ? { output: options.output } : {}),
546
+ plainResult: servicesNextPlainResult(envelope.result),
547
+ });
182
548
  }
183
549
  export async function runServicesList(backend, options = {}) {
184
- const response = await backend.listServiceExecutions(options.limit ?? 50);
185
- if (options.jsonOutput) {
186
- writeJSON(options.output, response.executions.map((model) => ({
187
- execution: model.execution,
188
- agent_guidance: buildServiceReadModelGuidance(model),
189
- })));
190
- return;
191
- }
192
- const out = resolveOutput(options.output);
193
- for (const model of response.executions) {
194
- const guidance = buildServiceReadModelGuidance(model);
195
- out(`${guidance.summary}\n`);
196
- const next = guidance.next_actions[0];
197
- if (next)
198
- out(`next: ${next.command}\n`);
550
+ const limit = options.limit ?? 10;
551
+ if (!Number.isInteger(limit) || limit < 1 || limit > 100) {
552
+ throw new CommandContractError("limit_invalid", "--limit must be an integer from 1 to 100", "使用 1 到 100 的整数 limit;本次未读取服务端列表。", [{ command: "itpay services list --limit 10 --json", reason: "使用默认上限重试" }]);
199
553
  }
554
+ const response = await backend.listServiceExecutions(limit);
555
+ const executions = response.executions.map(({ execution }) => ({
556
+ service_execution_id: execution.service_execution_id,
557
+ service_id: execution.service_id,
558
+ status: execution.status,
559
+ phase: execution.phase,
560
+ updated_at: execution.updated_at,
561
+ }));
562
+ const latest = executions[0];
563
+ const envelope = {
564
+ status: latest ? "listed" : "no_executions",
565
+ result: { executions },
566
+ instruction: latest
567
+ ? "结果按最新到最旧排列,默认只列最近 10 条;找不到目标时再扩大 limit。"
568
+ : "当前设备没有可恢复的 Service Execution;先读取已发布目录,不要猜测 ID。",
569
+ next: latest
570
+ ? { command: `itpay services next ${latest.service_execution_id} --json`, reason: "默认恢复最新执行" }
571
+ : { command: "itpay catalog list --json", reason: "选择已发布服务" },
572
+ recovery: [],
573
+ };
574
+ writeCommandEnvelope(envelope, {
575
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
576
+ ...(options.output ? { output: options.output } : {}),
577
+ plainResult: executions.map((execution) => `${execution.service_execution_id}: ${execution.service_id} ${execution.status}/${execution.phase} updated=${execution.updated_at}`),
578
+ });
200
579
  }
201
580
  export async function runServicesReadResult(backend, serviceExecutionID, options = {}) {
202
- writeJSON(options.output, await backend.getGrantedServiceResult(serviceExecutionID));
581
+ const envelope = grantedResultEnvelope(await backend.getGrantedServiceResult(serviceExecutionID));
582
+ writeCommandEnvelope(envelope, {
583
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
584
+ ...(options.output ? { output: options.output } : {}),
585
+ plainResult: servicesNextPlainResult(envelope.result),
586
+ });
587
+ }
588
+ function servicesNextEnvelope(model) {
589
+ const execution = model.execution;
590
+ const lockedRefund = model.refunds.find((refund) => refund.access_locked);
591
+ if (lockedRefund) {
592
+ const terminal = lockedRefund.status === "succeeded";
593
+ return {
594
+ status: "delivery_locked",
595
+ result: {
596
+ service_execution_id: execution.service_execution_id,
597
+ access_locked: true,
598
+ refund: {
599
+ refund_request_id: lockedRefund.refund_request_id,
600
+ status: lockedRefund.status,
601
+ },
602
+ },
603
+ instruction: terminal
604
+ ? "退款已成功,交付永久关闭;不要 reveal、创建 grant 或读取结果。"
605
+ : "退款处理中,交付已冻结;不要 reveal、创建 grant 或读取结果。",
606
+ next: terminal ? null : {
607
+ command: `itpay refund get ${lockedRefund.refund_request_id} --json`,
608
+ reason: "读取退款权威状态",
609
+ },
610
+ recovery: [],
611
+ };
612
+ }
613
+ if (isTerminalServiceExecutionStatus(execution.status)) {
614
+ return {
615
+ status: execution.status,
616
+ result: {
617
+ service_execution_id: execution.service_execution_id,
618
+ service_id: execution.service_id,
619
+ phase: execution.phase,
620
+ },
621
+ instruction: execution.status === "refunded"
622
+ ? "该服务执行已退款并永久结束;不要重放 capability 或创建 Checkout。"
623
+ : "该服务执行已结束;不要重放 capability 或创建 Checkout。",
624
+ next: null,
625
+ recovery: [{
626
+ command: `itpay services events ${execution.service_execution_id} --json`,
627
+ reason: "仅在需要诊断终止原因时读取事件",
628
+ }],
629
+ };
630
+ }
631
+ const currentItems = model.current_result_items ?? [];
632
+ const delivery = model.current_delivery ?? model.delivery_bindings.at(-1);
633
+ const deliveryMode = serviceDeliveryMode(model);
634
+ const candidateSelection = model.allowed_actions?.find((action) => action.type === "select_candidate");
635
+ if (candidateSelection && currentItems.length > 0) {
636
+ const paidCapability = delivery?.capability_id
637
+ ? model.capabilities.find((capability) => capability.capability_id === delivery.capability_id && capability.requires_payment)
638
+ : undefined;
639
+ return {
640
+ status: "candidate_selection_available",
641
+ result: {
642
+ service_execution_id: execution.service_execution_id,
643
+ ...(delivery?.capability_id ? { capability_id: delivery.capability_id } : {}),
644
+ ...(deliveryMode ? { delivery_mode: deliveryMode } : {}),
645
+ items: currentItems.map((item) => ({
646
+ rank: item.rank,
647
+ title: item.display_title,
648
+ safe_payload: item.safe_payload,
649
+ })),
650
+ },
651
+ instruction: paidCapability
652
+ ? "付费 Agent-visible 搜索已完成。现在把 items 中的编号、title 和 safe_payload 展示给用户,然后停止;不要调用 read-result。若用户目标只是候选搜索,任务已经完成。只有用户之后明确选择某个候选并要求继续时,才执行 next.command;不要自动购买后续报告。"
653
+ : "向用户展示编号和 safe_payload;若候选列表已满足用户目标,在此停止。仅在用户明确选择并希望继续时,才在当前 Execution 提交对应 rank。",
654
+ next: {
655
+ command: `itpay services action ${execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
656
+ reason: paidCapability ? "仅在用户明确选择候选并要求继续时执行" : "仅在用户明确选择后锁定来源候选",
657
+ },
658
+ recovery: [],
659
+ };
660
+ }
661
+ if (deliveryMode === "agent_visible_result") {
662
+ const items = currentItems.map((item) => ({
663
+ rank: item.rank,
664
+ title: item.display_title,
665
+ safe_payload: item.safe_payload,
666
+ }));
667
+ const selection = model.allowed_actions?.find((action) => action.type === "select_candidate");
668
+ return {
669
+ status: items.length > 0 ? "result_ready" : "no_result",
670
+ result: {
671
+ service_execution_id: execution.service_execution_id,
672
+ ...(delivery?.capability_id ? { capability_id: delivery.capability_id } : {}),
673
+ delivery_mode: deliveryMode,
674
+ items,
675
+ },
676
+ instruction: items.length > 0
677
+ ? selection
678
+ ? "Agent-visible 搜索已完成。向用户展示 items 中的编号、title 和 safe_payload,然后停止;不要调用 read-result。只有用户明确选择候选并要求继续时,才执行 next.command。"
679
+ : "这是当前 Graph 步骤对应的交付;结果已可供 Agent 使用,只使用 safe_payload。"
680
+ : "Agent-visible 交付已完成但没有结果项;不要调用 read-result 或重放当前 execution。",
681
+ next: selection ? {
682
+ command: `itpay services action ${execution.service_execution_id} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
683
+ reason: "仅在用户明确选择后锁定来源候选",
684
+ } : null,
685
+ recovery: items.length > 0 ? [] : [{ command: `itpay services get ${execution.service_execution_id} --json`, reason: "检查交付时间线" }],
686
+ };
687
+ }
688
+ if (deliveryMode === "vault_artifact") {
689
+ const grantStatus = normalizeGrantStatus(delivery?.grant_status);
690
+ const grantActive = grantStatus === "active";
691
+ return {
692
+ status: grantActive ? "grant_active" : "human_authorization_required",
693
+ result: {
694
+ service_execution_id: execution.service_execution_id,
695
+ ...(delivery?.capability_id ? { capability_id: delivery.capability_id } : {}),
696
+ delivery_mode: deliveryMode,
697
+ grant_status: grantStatus,
698
+ ...(grantActive && delivery?.grant_expires_at ? { grant_expires_at: delivery.grant_expires_at } : {}),
699
+ },
700
+ instruction: grantActive
701
+ ? "这是当前 Graph 步骤对应的交付;用户授权有效,立即读取并遵守字段范围与到期时间。"
702
+ : "这是当前 Graph 步骤对应的交付;请用户在订单页面授权,未授权前不要读取或猜测内容。",
703
+ next: {
704
+ command: `itpay services read-result ${execution.service_execution_id} --json`,
705
+ reason: grantActive ? "读取当前有效 grant 的结果" : "仅在用户确认授权后执行",
706
+ },
707
+ recovery: [],
708
+ };
709
+ }
710
+ const allowedActions = model.allowed_actions ?? [];
711
+ const preferred = allowedActions[0];
712
+ if (preferred?.type === "prepare_quote") {
713
+ const continuation = paidContinuation(model, preferred, {});
714
+ if (continuation) {
715
+ return {
716
+ status: execution.status,
717
+ result: {
718
+ service_execution_id: execution.service_execution_id,
719
+ service_id: execution.service_id,
720
+ phase: execution.phase,
721
+ checkout: continuation.checkout,
722
+ },
723
+ instruction: purchaseConfirmationInstruction(execution.status === "quota_exhausted" ? "quota_exhausted" : "candidate_selected", continuation.price, continuation.capability.delivery_email_required),
724
+ next: continuation.next,
725
+ recovery: [],
726
+ };
727
+ }
728
+ }
729
+ const next = preferred ? serviceAllowedActionCommand(model, preferred) : null;
730
+ return {
731
+ status: execution.status,
732
+ result: {
733
+ service_execution_id: execution.service_execution_id,
734
+ service_id: execution.service_id,
735
+ phase: execution.phase,
736
+ allowed_actions: allowedActions.map((action) => ({
737
+ type: action.type,
738
+ ...(action.capability_id ? { capability_id: action.capability_id } : {}),
739
+ requires_human: action.requires_human,
740
+ })),
741
+ },
742
+ instruction: preferred?.type === "resume_checkout"
743
+ ? "当前 Execution 已经有一笔 Checkout。不要创建新的 Quote、Cart、Checkout 或 Execution。现在只执行 next.command,恢复并展示同一 Checkout 的付款入口。"
744
+ : preferred?.type === "wait"
745
+ ? "付款已确认,Provider 正在处理当前 Execution。不要新建 Execution、Checkout 或再次付款;稍后只执行 next.command 查询同一 Execution。"
746
+ : preferred?.requires_human
747
+ ? "当前下一步需要用户明确选择;先展示必要信息并等待确认。"
748
+ : preferred ? "执行服务端返回的唯一首选动作;不要猜测其他 capability。" : "当前没有后续动作。",
749
+ next,
750
+ recovery: [{ command: `itpay services get ${execution.service_execution_id} --json`, reason: "仅在当前动作异常时检查时间线" }],
751
+ };
752
+ }
753
+ function serviceAllowedActionCommand(model, action) {
754
+ const executionID = model.execution.service_execution_id;
755
+ const capability = action.capability_id
756
+ ? model.capabilities.find((item) => item.capability_id === action.capability_id)
757
+ : undefined;
758
+ switch (action.type) {
759
+ case "invoke_capability": {
760
+ if (!capability)
761
+ return null;
762
+ const input = Object.fromEntries(requiredInputFields(capability.input_schema).map((field) => [field, "<value>"]));
763
+ return {
764
+ command: `itpay services invoke ${executionID} --capability ${capability.capability_id}${formatInputOptions(input)} --json`,
765
+ reason: "执行当前允许的 Agent-visible capability",
766
+ };
767
+ }
768
+ case "select_candidate":
769
+ return {
770
+ command: `itpay services action ${executionID} --action select_candidate --actor-type human --status approved --candidate <rank> --json`,
771
+ reason: "仅在用户明确选择后提交当前候选 rank",
772
+ };
773
+ case "prepare_quote": {
774
+ return paidContinuation(model, action, {})?.next ?? null;
775
+ }
776
+ case "resume_checkout":
777
+ return { command: `itpay services checkout ${executionID} --resume --json`, reason: "恢复同一 Checkout,不创建第二笔" };
778
+ case "wait":
779
+ return { command: `itpay services next ${executionID} --json`, reason: "等待 durable execution 推进" };
780
+ case "view_delivery":
781
+ return { command: `itpay services next ${executionID} --json`, reason: "读取当前交付模式" };
782
+ default:
783
+ return null;
784
+ }
785
+ }
786
+ function serviceDeliveryMode(model) {
787
+ const delivery = model.current_delivery ?? model.delivery_bindings.at(-1);
788
+ const explicit = String(delivery?.redacted_summary?.delivery_mode ?? "");
789
+ if (explicit)
790
+ return explicit;
791
+ return delivery?.vault_artifact_id ? "vault_artifact" : "";
792
+ }
793
+ function normalizeGrantStatus(status) {
794
+ return !status || status === "missing" ? "none" : status;
795
+ }
796
+ function grantedResultEnvelope(response) {
797
+ return {
798
+ status: "granted_result_ready",
799
+ result: {
800
+ service_execution_id: response.service_execution_id,
801
+ ...(response.expires_at ? { grant_expires_at: response.expires_at } : {}),
802
+ granted_fields: Object.keys(response.result),
803
+ payload: response.result,
804
+ },
805
+ instruction: "结果来自当前有效 Vault Grant;只使用本次授权字段,过期后停止读取并重新请求用户同意。",
806
+ next: null,
807
+ recovery: [],
808
+ };
809
+ }
810
+ function servicesNextPlainResult(result) {
811
+ const lines = [];
812
+ for (const [key, value] of Object.entries(result)) {
813
+ if (key === "items" && Array.isArray(value)) {
814
+ lines.push("items:");
815
+ for (const item of value) {
816
+ lines.push(` ${item.rank}. ${item.title}`);
817
+ for (const [field, fieldValue] of Object.entries(item.safe_payload)) {
818
+ lines.push(` ${field}: ${typeof fieldValue === "string" ? fieldValue : JSON.stringify(fieldValue)}`);
819
+ }
820
+ }
821
+ continue;
822
+ }
823
+ lines.push(`${key}: ${typeof value === "string" ? value : JSON.stringify(value)}`);
824
+ }
825
+ return lines;
203
826
  }
204
827
  export async function runServicesEvents(backend, serviceExecutionID, options = {}) {
205
- writeJSON(options.output, await backend.listServiceExecutionEvents(serviceExecutionID));
828
+ const afterSequence = options.afterSequence ?? 0;
829
+ const limit = options.limit ?? 50;
830
+ if (!serviceExecutionID.trim()) {
831
+ throw new CommandContractError("service_execution_id_required", "service execution id is required", "使用 services list 返回的 execution ID;不要猜测。", [{ command: "itpay services list --json", reason: "列出当前身份可见执行" }]);
832
+ }
833
+ if (!Number.isSafeInteger(afterSequence) || afterSequence < 0) {
834
+ throw new CommandContractError("events_parameter_invalid", "after_sequence must be a non-negative integer", "--after-sequence 必须是非负整数;本次未读取事件。", [{ command: `itpay services events ${serviceExecutionID} --help`, reason: "查看诊断参数" }]);
835
+ }
836
+ if (!Number.isSafeInteger(limit) || limit < 1 || limit > 100) {
837
+ throw new CommandContractError("events_parameter_invalid", "limit must be an integer between 1 and 100", "--limit 必须是 1 到 100 的整数;本次未读取事件。", [{ command: `itpay services events ${serviceExecutionID} --help`, reason: "查看诊断参数" }]);
838
+ }
839
+ const response = await backend.listServiceExecutionEvents(serviceExecutionID, afterSequence, limit);
840
+ const events = response.events.map((event) => ({
841
+ sequence: event.sequence,
842
+ type: event.type,
843
+ status: event.status,
844
+ phase: event.phase,
845
+ ...(event.capability_id ? { capability_id: event.capability_id } : {}),
846
+ occurred_at: event.occurred_at,
847
+ }));
848
+ writeCommandEnvelope({
849
+ status: "listed",
850
+ result: {
851
+ service_execution_id: serviceExecutionID,
852
+ after_sequence: afterSequence,
853
+ returned_count: events.length,
854
+ events,
855
+ },
856
+ instruction: "事件仅用于诊断;不要从事件重放业务步骤,回到 services next 获取当前动作。",
857
+ next: {
858
+ command: `itpay services next ${serviceExecutionID} --json`,
859
+ reason: "恢复正常服务流程",
860
+ },
861
+ recovery: events.length === limit && events.length > 0
862
+ ? [{
863
+ command: `itpay services events ${serviceExecutionID} --after-sequence ${events.at(-1).sequence} --limit ${limit} --json`,
864
+ reason: "继续读取下一页诊断事件",
865
+ }]
866
+ : [],
867
+ }, {
868
+ ...(options.jsonOutput !== undefined ? { jsonOutput: options.jsonOutput } : {}),
869
+ ...(options.output ? { output: options.output } : {}),
870
+ plainResult: [
871
+ `service_execution_id: ${serviceExecutionID}`,
872
+ `returned_count: ${events.length}`,
873
+ ...events.map((event) => `${event.sequence} ${event.occurred_at} ${event.type} ${event.status}/${event.phase}`),
874
+ ],
875
+ });
206
876
  }
207
877
  export function parseKeyValueList(values) {
208
878
  const result = {};
@@ -228,81 +898,63 @@ function parseValue(value) {
228
898
  return Number(value);
229
899
  return value;
230
900
  }
231
- function writeJSON(output, value) {
232
- resolveOutput(output)(JSON.stringify(value, null, 2) + "\n");
233
- }
234
- function buildServicesCheckoutJSON(response, checkoutURL, plan) {
901
+ function buildServicesCheckoutEnvelope(response, checkoutURL, plan, baseURL) {
235
902
  const checkout = response.checkout;
236
- const output = {
237
- kind: "checkout_handoff_required",
238
- next_action: "open_human_checkout",
239
- service_execution_id: response.binding.service_execution_id,
240
- service_quote_lock_id: response.service_quote_lock_id,
241
- checkout_id: checkout.checkout.checkout_id,
242
- checkout_status: checkout.checkout.status,
243
- handoff_reissued: response.handoff_reissued,
244
- checkout_url: checkoutURL,
245
- display_token: checkout.display_token,
246
- qr_payload: checkout.qr_payload,
247
- amount_minor: checkout.checkout.amount_minor,
248
- currency: checkout.checkout.currency,
249
- next: `itpay checkout --id ${checkout.checkout.checkout_id} --token ${checkout.display_token}`,
250
- warning: "Do not call `itpay pay` for the normal buyer flow; show this ItPay checkout URL/QR to the human.",
251
- };
252
- const nextActions = [{
253
- id: "open_human_checkout",
254
- label: "Show the ItPay checkout URL or branded QR to the human",
255
- command: `itpay checkout --id ${checkout.checkout.checkout_id} --token ${checkout.display_token}`,
256
- requires_human: true,
257
- reason: "The human must review and pay on the ItPay checkout page.",
258
- }];
259
- output.next_actions = nextActions;
260
- output.agent_guidance = {
261
- kind: "checkout_handoff",
262
- summary: `checkout ${checkout.checkout.checkout_id}: show the ItPay checkout handoff to the human`,
263
- state: {
903
+ const platform = platformKeyForHost(plan.host);
904
+ const handoff = { url: checkoutURL };
905
+ if (plan.ideImageAttach?.status === "downloaded" && plan.ideImageAttach.localPath) {
906
+ handoff.qr_local_path = plan.ideImageAttach.localPath;
907
+ }
908
+ if (platform === "markdown") {
909
+ handoff.markdown = buildAgentChatHandoff(plan).markdown;
910
+ }
911
+ else if (platform === "plain_chat" && checkout.qr_png_url) {
912
+ handoff.qr_image_url = absolutePublicURL(baseURL, checkout.qr_png_url);
913
+ }
914
+ const amount = formatMoney(checkout.checkout.amount_minor, checkout.checkout.currency);
915
+ return {
916
+ status: "human_checkout_required",
917
+ result: {
264
918
  service_execution_id: response.binding.service_execution_id,
265
919
  checkout_id: checkout.checkout.checkout_id,
266
- checkout_status: checkout.checkout.status,
920
+ capability_id: checkoutCapabilityID(response),
921
+ locked_input: response.locked_input,
922
+ amount,
267
923
  },
268
- next_actions: nextActions,
269
- recovery: [{
270
- id: "inspect_checkout",
271
- label: "Read checkout presentation",
272
- command: `itpay checkout --id ${checkout.checkout.checkout_id} --token ${checkout.display_token}`,
273
- }],
924
+ handoff,
925
+ instruction: checkoutInstruction(platform, amount),
926
+ next: {
927
+ command: `itpay checkout --id ${checkout.checkout.checkout_id} --token ${checkout.display_token} --json`,
928
+ reason: "仅在用户完成付款操作或要求查询后,读取同一 Checkout 的权威状态",
929
+ },
930
+ recovery: [],
274
931
  };
275
- if (checkout.qr_png_url) {
276
- output.qr_png_url = checkout.qr_png_url;
277
- }
278
- if (plan.ideImageAttach) {
279
- output.brand_qr_status = plan.ideImageAttach.status;
280
- output.brand_qr_mime_type = plan.ideImageAttach.mimeType;
281
- output.brand_qr_must_render_reason = plan.ideImageAttach.mustRenderReason;
282
- output.brand_qr_render_action = "agent_must_read_local_path_into_ide_chat";
283
- if (plan.ideImageAttach.localPath) {
284
- output.brand_qr_local_path = plan.ideImageAttach.localPath;
285
- const stableName = plan.ideImageAttach.localPath.split("/").pop();
286
- if (stableName)
287
- output.brand_qr_stable_name = stableName;
288
- }
289
- if (plan.ideImageAttach.mirrors.length > 0) {
290
- output.brand_qr_mirrors = [...plan.ideImageAttach.mirrors];
291
- }
292
- if (plan.ideImageAttach.caption) {
293
- output.brand_qr_caption = plan.ideImageAttach.caption;
294
- }
295
- if (plan.ideImageAttach.error) {
296
- output.brand_qr_error = plan.ideImageAttach.error;
297
- }
932
+ }
933
+ function checkoutCapabilityID(response, fallback = "") {
934
+ return response.capability_id || fallback;
935
+ }
936
+ function checkoutInstruction(platform, amount) {
937
+ if (platform === "markdown")
938
+ return `把 handoff.markdown 原样发送到当前桌面对话,确认二维码、付款链接和金额 ${amount} 都已实际对用户可见,然后停止等待。不要立即执行 next.command,不要创建第二个 Checkout、Execution 或调用 pay;用户完成付款操作或要求查询后,只执行 next.command。`;
939
+ if (platform === "terminal")
940
+ return `在用户可见终端展示二维码、付款链接和金额 ${amount},然后停止等待。不要立即执行 next.command,不要创建第二个 Checkout、Execution 或调用 pay;用户完成付款操作或要求查询后,只执行 next.command。`;
941
+ return `现在只做以下动作:1)把 handoff.url 作为可点击付款链接发送给用户;2)优先把 handoff.qr_local_path 作为图片附件发送,不能发送本地附件时使用 handoff.qr_image_url;3)明确告诉用户本次金额是 ${amount};4)发送完成后停止并等待用户操作。不要立即执行 next.command,不要创建第二个 Checkout,不要新建 Execution,不要调用 pay。用户表示已经完成付款或要求查询状态后,只执行 next.command;用户的话本身不是付款成功证明。`;
942
+ }
943
+ function absolutePublicURL(baseURL, value) {
944
+ try {
945
+ return new URL(value, baseURL.endsWith("/") ? baseURL : `${baseURL}/`).toString();
946
+ }
947
+ catch {
948
+ return value;
298
949
  }
299
- output.agent_action = buildAgentChatHandoff(plan);
300
- return output;
301
950
  }
302
- function normalizeServiceActionStatus(status) {
951
+ function formatMoney(amountMinor, currency) {
952
+ return `${(amountMinor / 100).toFixed(2)} ${currency}`;
953
+ }
954
+ function normalizeServiceActionStatus(status, serviceExecutionID) {
303
955
  const normalized = status.trim().toLowerCase();
304
956
  if (!serviceActionStatuses.has(normalized)) {
305
- throw new Error(`invalid --status "${status}". Supported: pending, approved, rejected, expired, cancelled`);
957
+ throw actionInputError(serviceExecutionID, `invalid --status "${status}". Supported: pending, approved, rejected, expired, cancelled`);
306
958
  }
307
959
  return normalized;
308
960
  }