@itpay/cli 2.1.17 → 2.1.19

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.
@@ -43,12 +43,25 @@ export class DeviceAuthority {
43
43
  }
44
44
  async ensureAuthorization() {
45
45
  if (!this.pending) {
46
- this.pending = withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization()).finally(() => {
46
+ this.pending = this.cachedAuthorization() ?? withFileLock(`${this.statePath}.lock`, () => this.prepareAuthorization());
47
+ this.pending = this.pending.finally(() => {
47
48
  this.pending = undefined;
48
49
  });
49
50
  }
50
51
  return this.pending;
51
52
  }
53
+ cachedAuthorization() {
54
+ const agentType = this.requestedAgentType;
55
+ if (!agentType)
56
+ return undefined;
57
+ const state = this.readState();
58
+ const registration = state?.registrations[this.backendKey];
59
+ const session = registration?.sessions[agentType];
60
+ if (!registration?.agentInstances[agentType] || !session || Date.parse(session.expiresAt) <= Date.now() + 60_000)
61
+ return undefined;
62
+ const privateKey = this.readPrivateKey();
63
+ return privateKey ? Promise.resolve({ state: registration, agentType, session, privateKey }) : undefined;
64
+ }
52
65
  async recoverAuthorization() {
53
66
  await withFileLock(`${this.statePath}.lock`, async () => {
54
67
  const state = this.readState();
@@ -61,6 +74,9 @@ export class DeviceAuthority {
61
74
  this.writeState(state);
62
75
  });
63
76
  }
77
+ repairLock() {
78
+ return inspectAndRecoverLock(`${this.statePath}.lock`);
79
+ }
64
80
  async recoverBackendReset() {
65
81
  return withFileLock(`${this.statePath}.lock`, async () => {
66
82
  const state = this.readState();
@@ -236,6 +252,7 @@ export class DeviceAuthority {
236
252
  method: "POST",
237
253
  headers: { "Content-Type": "application/json", Accept: "application/json", ...this.compatibilityHeaders, ...extraHeaders },
238
254
  body,
255
+ signal: AbortSignal.timeout(15_000),
239
256
  });
240
257
  const payload = await response.json().catch(() => ({}));
241
258
  if (!response.ok)
@@ -300,6 +317,13 @@ export class DeviceStateError extends Error {
300
317
  this.name = "DeviceStateError";
301
318
  }
302
319
  }
320
+ export class DeviceLockBusyError extends Error {
321
+ code = "device_lock_busy";
322
+ constructor() {
323
+ super("ItPay device identity is being updated by another process");
324
+ this.name = "DeviceLockBusyError";
325
+ }
326
+ }
303
327
  function emptyDeviceState() {
304
328
  return { schemaVersion: "itpay.device.v2", registrations: {} };
305
329
  }
@@ -344,9 +368,9 @@ async function withFileLock(path, run) {
344
368
  catch (error) {
345
369
  throw asDeviceStatePathError(error, "prepare_lock") ?? error;
346
370
  }
347
- const ownerToken = randomUUID();
371
+ const ownerToken = `${process.pid}:${randomUUID()}`;
348
372
  let acquired = false;
349
- for (let attempt = 0; attempt < 200; attempt += 1) {
373
+ for (let attempt = 0; attempt < 800; attempt += 1) {
350
374
  try {
351
375
  writeFileSync(path, ownerToken, { encoding: "utf8", flag: "wx", mode: 0o600 });
352
376
  chmodSync(path, 0o600);
@@ -357,20 +381,12 @@ async function withFileLock(path, run) {
357
381
  const code = error.code;
358
382
  if (code !== "EEXIST")
359
383
  throw asDeviceStateError(error, "acquire_lock") ?? error;
360
- try {
361
- if (Date.now() - statSync(path).mtimeMs > 30_000)
362
- moveLockAside(path, "stale", "remove_stale_lock");
363
- }
364
- catch (statError) {
365
- if (statError.code !== "ENOENT") {
366
- throw asDeviceStateError(statError, "inspect_lock") ?? statError;
367
- }
368
- }
384
+ inspectAndRecoverLock(path);
369
385
  await new Promise((resolve) => setTimeout(resolve, 25));
370
386
  }
371
387
  }
372
388
  if (!acquired)
373
- throw new Error("timed out waiting for ItPay device identity lock");
389
+ throw new DeviceLockBusyError();
374
390
  try {
375
391
  return await run();
376
392
  }
@@ -378,6 +394,46 @@ async function withFileLock(path, run) {
378
394
  releaseLock(path, ownerToken);
379
395
  }
380
396
  }
397
+ function inspectAndRecoverLock(path) {
398
+ let token;
399
+ let age;
400
+ try {
401
+ const lockStat = statSync(path);
402
+ age = Date.now() - lockStat.mtimeMs;
403
+ token = lockStat.isDirectory() ? "" : readFileSync(path, "utf8");
404
+ }
405
+ catch (error) {
406
+ if (error.code === "ENOENT")
407
+ return "absent";
408
+ throw asDeviceStateError(error, "inspect_lock") ?? error;
409
+ }
410
+ const ownerPID = /^(\d+):[0-9a-f-]+$/.exec(token)?.[1];
411
+ if (ownerPID) {
412
+ try {
413
+ process.kill(Number(ownerPID), 0);
414
+ return "active";
415
+ }
416
+ catch (error) {
417
+ if (error.code !== "ESRCH")
418
+ return "active";
419
+ }
420
+ }
421
+ else if (age <= 30_000) {
422
+ return "active";
423
+ }
424
+ // Recheck the exact owner before moving the lock; another process may have renewed it.
425
+ try {
426
+ if (token ? readFileSync(path, "utf8") !== token : !statSync(path).isDirectory())
427
+ return "active";
428
+ }
429
+ catch (error) {
430
+ if (error.code === "ENOENT")
431
+ return "absent";
432
+ throw asDeviceStateError(error, "inspect_lock") ?? error;
433
+ }
434
+ moveLockAside(path, "stale", "remove_stale_lock");
435
+ return "recovered";
436
+ }
381
437
  function releaseLock(path, ownerToken) {
382
438
  try {
383
439
  if (readFileSync(path, "utf8") !== ownerToken)
@@ -0,0 +1,88 @@
1
+ // Minimal local task journal: which Service Executions are paused at a human
2
+ // gate (login, rate limit) and how to resume the SAME execution. It stores
3
+ // no chat history, PII, credentials or tokens — only ids, stage and the
4
+ // resume command the owner flow already emitted. Business truth stays
5
+ // server-side; this file only makes "continue" point at the right task.
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
7
+ import { dirname } from "node:path";
8
+ export const PAUSED_STAGES = new Set([
9
+ "login_required",
10
+ "rate_limited",
11
+ "quota_paused",
12
+ "auth_pending",
13
+ "awaiting_input",
14
+ ]);
15
+ const TERMINAL_STAGES = new Set([
16
+ "completed",
17
+ "delivered",
18
+ "issued",
19
+ "failed",
20
+ "cancelled",
21
+ "refunded",
22
+ ]);
23
+ export class TaskJournal {
24
+ path;
25
+ constructor(path) {
26
+ this.path = path;
27
+ }
28
+ record(task) {
29
+ const file = this.readFile();
30
+ file.tasks[task.service_execution_id] = {
31
+ ...task,
32
+ updated_at: new Date().toISOString(),
33
+ };
34
+ this.writeFile(file);
35
+ }
36
+ clear(serviceExecutionID) {
37
+ const file = this.readFile();
38
+ if (!file.tasks[serviceExecutionID])
39
+ return;
40
+ delete file.tasks[serviceExecutionID];
41
+ this.writeFile(file);
42
+ }
43
+ // Observe the latest stage without deleting history; terminal stages stop
44
+ // the task from counting as resumable.
45
+ observe(serviceExecutionID, stage, resumeCommand) {
46
+ const file = this.readFile();
47
+ const existing = file.tasks[serviceExecutionID];
48
+ file.tasks[serviceExecutionID] = {
49
+ service_execution_id: serviceExecutionID,
50
+ ...(existing?.service_id ? { service_id: existing.service_id } : {}),
51
+ stage,
52
+ ...(resumeCommand ?? existing?.resume_command ? { resume_command: resumeCommand ?? existing.resume_command } : {}),
53
+ updated_at: new Date().toISOString(),
54
+ };
55
+ this.writeFile(file);
56
+ }
57
+ // Tasks paused behind login/quota gates, newest first. Terminal or
58
+ // completed tasks never count as "still waiting for login".
59
+ pausedTasks() {
60
+ const file = this.readFile();
61
+ return Object.values(file.tasks)
62
+ .filter((task) => PAUSED_STAGES.has(task.stage) && !TERMINAL_STAGES.has(task.stage))
63
+ .sort((left, right) => right.updated_at.localeCompare(left.updated_at));
64
+ }
65
+ readFile() {
66
+ if (!existsSync(this.path))
67
+ return { schema_version: "itpay.task_journal.v1", tasks: {} };
68
+ try {
69
+ const parsed = JSON.parse(readFileSync(this.path, "utf8"));
70
+ if (parsed.schema_version !== "itpay.task_journal.v1" || !parsed.tasks || typeof parsed.tasks !== "object") {
71
+ return { schema_version: "itpay.task_journal.v1", tasks: {} };
72
+ }
73
+ return parsed;
74
+ }
75
+ catch {
76
+ return { schema_version: "itpay.task_journal.v1", tasks: {} };
77
+ }
78
+ }
79
+ writeFile(file) {
80
+ // Bound the journal: keep only the 50 most recent task records.
81
+ const entries = Object.entries(file.tasks).sort((left, right) => right[1].updated_at.localeCompare(left[1].updated_at));
82
+ file.tasks = Object.fromEntries(entries.slice(0, 50));
83
+ mkdirSync(dirname(this.path), { recursive: true, mode: 0o700 });
84
+ const tmp = `${this.path}.${process.pid}.tmp`;
85
+ writeFileSync(tmp, JSON.stringify(file), { mode: 0o600 });
86
+ renameSync(tmp, this.path);
87
+ }
88
+ }
@@ -90,5 +90,13 @@
90
90
  "lock",
91
91
  "window",
92
92
  "chat"
93
- ]
93
+ ],
94
+ "auth_session_states": {
95
+ "stages": "The CLI now reports the auth session stage exactly as the server returns it: waiting_provider (show the handoff and wait), email_verification_required (the human must verify the emailed code on the auth page), merge_confirmation_required (an existing account matches — the human must confirm the merge on the auth page), provider_pending/pending (generic wait), completed (session done — bind it, do not restart).",
96
+ "terminal_states": "auth_expired/auth_denied/auth_cancelled/auth_failed mean this session is over; say so plainly and start a NEW session only if the human still wants in — never keep polling a dead one.",
97
+ "transport_unknown": "auth_session_unknown means a network error while polling — the outcome is unknown. Keep waiting on the SAME session (it may still complete); do NOT restart auth, and do not assume denial.",
98
+ "missing": "auth_session_not_found means the cached session is gone server-side; start a new login.",
99
+ "completed_resume": "When login completes, itpay auth login resumes the remembered pending service execution automatically when one exists — you do not need to find it yourself.",
100
+ "path_b": "A buyer who reaches checkout without a login completes identity inside the protected checkout page; never force a separate itpay auth login first."
101
+ }
94
102
  }
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schema_version": "itp.agent_doc.v1",
3
3
  "role": "buyer",
4
- "product_scope": "itpay is the single public CLI entry point, and $itpay is its user-facing Skill invocation. Under that one product entry point, the two top-level commerce actions are buy and sell: Buyer workflows are available now; Seller workflows will use the same entry point and are not implemented yet.",
4
+ "product_scope": "itpay is the public CLI entry point for Buyer services and existing itpay sell Seller workflows.",
5
5
  "topic": "install-and-setup",
6
6
  "title": "Install And Identify The ItPay Agent Runtime",
7
7
  "purpose": "Select the real Agent Type, verify one official Backend, and load the packaged Skill through one linear setup path.",
@@ -32,7 +32,7 @@
32
32
  }
33
33
  ],
34
34
  "agent_rules": [
35
- "Start with install, identify the current real runtime from result.agent_types, run itpay install <agent_type> --json yourself, and then execute each returned next.command unchanged until the Skill is loaded.",
35
+ "For first setup or a runtime change, identify the real Agent Type once, run itpay install <agent_type> --json yourself, and follow its setup continuation until the packaged Skill is loaded. Ordinary service requests start from the Skill's service command, without repeating install or readyz.",
36
36
  "After setup, tell the human only that ItPay is ready; never ask them to run setup commands or explain technical setup steps.",
37
37
  "Use the CLI distribution already provided by the current npm package, Skill, or plugin; update only through that distribution's own instructions.",
38
38
  "Production defaults to https://app.itpay.ai. Testing may set ITPAY_BACKEND_URL to the exact official URL https://sandbox.itpay.ai; no other Backend is allowed.",
@@ -13,9 +13,9 @@
13
13
  "A query returned login_required or rate_limited."
14
14
  ],
15
15
  "service_selection": {
16
- "itpay-rail-exact": "Use when the buyer knows BOTH the exact departure and arrival railway station names (e.g. 古镇 → 广州南). Direct train query, fastest path. Do NOT use it with only a city or fuzzy location.",
16
+ "itpay-rail-exact": "Use when both exact railway station names are known, or when a few credible station-pair hypotheses can be checked with exact (e.g. 古镇 → 广州南). It is one station-pair + one travel_date query: filter the returned list locally for remembered train numbers or time windows — do NOT re-query per train. Do not silently treat a city name as its station.",
17
17
  "itpay-rail-smart": "Use when the buyer has a city name, an area, an address, or wants transfer planning and recommendations. The platform resolves locations to stations itself; never silently convert a city into its namesake station.",
18
- "itpay-rail-booking": "Purchase service — use ONLY after the buyer explicitly chose a train and seat from a query result. Starts quote → protected checkout → payment → asynchronous issuance on one execution. Never queried for schedules.",
18
+ "itpay-rail-booking": "Purchase service — use after the buyer chooses a train/seat or delegates selection under clear rules. Starts draft/review → live quote → protected checkout → asynchronous issuance on one execution. Never queried for schedules.",
19
19
  "svc_qizhidao_company_lookup": "Not a railway service; do not mix intents."
20
20
  },
21
21
  "input_contract": {
@@ -62,22 +62,22 @@
62
62
  "passengers": "Integer 1-5: number of passengers, NOT passenger identity records.",
63
63
  "selection": {
64
64
  "token": "The booking_offer.selection_token carried on the chosen result item.",
65
- "seat_type": "Seat code when the user picked a specific class, e.g. O."
65
+ "seat_type": "Use the seat_type code shown with seat_name in the returned booking_template.seat_choices (e.g. O = 二等座); never submit the display name as the code. Omit this optional field when the returned offer has no code; never guess."
66
66
  }
67
67
  },
68
68
  "explicit_legs": {
69
69
  "passengers": "Integer 1-5.",
70
- "legs": "Array of 1-3 legs, each: from_station_code, to_station_code, travel_date, train_code, departure_time (HH:MM), arrival_time (HH:MM), arrival_days (int 0-7), seat_type. Only use when no booking_offer is available; never guess these values."
70
+ "legs": "Array with exactly ONE leg: from_station_code, to_station_code, travel_date, train_code, departure_time (HH:MM), arrival_time (HH:MM), arrival_days (int 0-7), seat_type. The public review accepts a single leg; only use when no booking_offer is available, and never guess these values. For v2 separate_legs_only journeys, book one leg at a time via that leg's own booking_offer — never a multi-leg basket."
71
71
  },
72
72
  "boundary": "Identity fields (names, ID numbers, phones) never belong in this input; they are collected on the protected checkout page."
73
73
  }
74
74
  },
75
75
  "selection_flow": [
76
76
  "Exact results carry per-train candidates; smart results carry journeys. Bookable options carry booking_offer with a server-signed selection_token.",
77
- "The token binds the stored result snapshot and the chosen option. Submit it with passengers (and seat_type when the user picked a class); the server derives the authoritative legs and re-quotes live. Do NOT hand-build legs JSON from the visible result.",
78
- "An expired or foreign token returns a clear error; recover by running a fresh query or by explicit legs — never by guessing token contents.",
77
+ "The token binds the stored result snapshot and the chosen option. Submit it with passengers (and the exact seat_type code from booking_template.seat_choices when the user picked a class); the server derives the authoritative legs and re-quotes live. Do NOT hand-build legs JSON from the visible result. If only the code spelling is corrected and the confirmed train, actual seat class, passenger count, price, and terms remain identical, reuse the buyer's explicit approval. Material draft changes or missing approval require confirmation.",
78
+ "A historical selection identifies the original choice; booking refreshes current fare and inventory. A foreign buyer is rejected. Never guess token contents or hand-build legs as recovery.",
79
79
  "Repeating the same selection restores the same booking execution instead of creating a second one.",
80
- "v2 single-leg cards carry an opaque rsel_ selection_token bound to owner, execution, query revision, snapshot, journey/offer, policy and expiry; a stale or foreign token returns selection_stale — re-read the snapshot for a fresh offer instead of retrying.",
80
+ "v2 single-leg cards carry an opaque rsel_ selection_token bound to the buyer and saved journey/offer. The original selection can enter booking after its search window expires; live quote determines whether that exact train and seat remain purchasable.",
81
81
  "v2 separate_legs_only journeys never carry a whole-journey token: open the journey detail, pick one leg, and use that leg's owner-issued booking_offer; each leg is quoted and confirmed independently with no joint guarantee."
82
82
  ],
83
83
  "trial_and_login": {
@@ -88,10 +88,10 @@
88
88
  "purchase_after_trial": "Selecting a saved option and buying it does not consume search quota and works even when trial is exhausted."
89
89
  },
90
90
  "flow": [
91
- "services start <service> --json returns input_required plus the guidance block for rail services.",
92
- "Submit input with services run <service> --execution <id> --input-json <file> --json on the SAME execution.",
91
+ "For a known railway service, submit input directly with services run <service> --input-json <file> --json; keep the returned execution for follow-ups.",
93
92
  "itpay-rail-smart may stop at a location confirmation step; show the candidate places to the buyer, then continue the same execution after the human confirms.",
94
93
  "After the buyer explicitly picks a train/seat, run itpay-rail-booking with the selection token (preferred) or explicit legs. The execution then pauses at the booking review (workflow:confirm_booking): services next returns booking_review_required with human_action.context.review carrying draft_revision, legs, seat_options and notice_version.",
94
+ "Whether or not the query was free, the answer is the same shape under compare: one preferred option, at most two meaningful alternatives, the key trade-offs — then stop and wait for the buyer. Do not stack more options or chain into purchase.",
95
95
  "Collect the review input and submit it with services action <id> --action workflow:confirm_booking --actor-type human --status approved --input-json <file> --json. Required fields: draft_revision (exact server value), passengers, seat_type, seat_preferences (one entry per passenger_index 0..n-1, values limited to that leg's seat_options), party_preference, fallback=automatic_assignment, notice_version, accept_non_guaranteed=true. Only after an accepted review does the server quote and offer checkout.",
96
96
  "The booking execution reaches a protected checkout: passenger identity data is entered by the human on that page, never through the CLI. Account authentication may happen inside checkout — do not force a separate auth login first (Path B).",
97
97
  "Payment success does not mean the ticket is issued. Issuance is asynchronous; keep reading the same execution with services next.",
@@ -121,7 +121,8 @@
121
121
  "revision": "While payment has not started, the same execution accepts a revised review: resubmit confirm_booking with the new draft_revision; the old unpaid quote/checkout is retired and re-quoted. After a payment attempt the review is closed — never create a replacement purchase."
122
122
  },
123
123
  "agent_rules": [
124
- "Pick the service by the buyer's input precision: exact station names → itpay-rail-exact; anything fuzzier → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
124
+ "Pick the service by the buyer's input precision: known stations or a few credible station-pair hypotheses → itpay-rail-exact; broader fuzzy locations → itpay-rail-smart; explicit purchase intent after a selection → itpay-rail-booking.",
125
+ "Choose the goal first (see goals): compare presents one preferred option plus at most two meaningful alternatives then waits; prepare_checkout selects under the buyer's rules and continues to the official confirmation entry without re-asking.",
125
126
  "Never invent input field names. If the guidance block or this doc does not list a field, do not send it.",
126
127
  "Never ask the buyer for passenger names, ID numbers, or phone numbers in chat; those are collected only on the protected checkout page.",
127
128
  "Never reconstruct provider-shaped legs JSON when a booking_offer.selection_token is available; submit the token plus passengers.",
@@ -187,5 +188,17 @@
187
188
  "journey_card": "Cards carry station names, train codes, dated times, transfer/same-run notes, observed_at price time and handles. representative_offer.ticket_offers[] lists one purchasable segment per entry (leg_index, offer_id, service_ref, station codes, travel_date, seat_type, passengers, integer-minor amounts); per-item amounts sum to the total. rides[] (ridden legs) and ticket_offers[] (purchasable segments) are different lists — never infer transfers or fees from array lengths.",
188
189
  "selection": "booking_support=single_leg cards carry booking_offer {service_id: itpay-rail-booking, selection_token: rsel_…}; submit it to itpay-rail-booking with passengers as today. separate_legs_only cards expose no whole-journey token — each leg is quoted and confirmed separately via its protected detail; never assemble legs JSON yourself. booking_support=none has no selection action.",
189
190
  "honesty": "Coverage is bounded: repeat searched_scope/notices verbatim, never claim national-exhaustive search, and surface missing prices as unknown — never 0."
191
+ },
192
+ "goals": {
193
+ "compare": "Buyer wants to see options and keeps the final choice. Present one preferred option plus at most two meaningful alternatives, then wait. A fully-specified query with no purchase delegation stays compare.",
194
+ "prepare_checkout": "Buyer named the product, or delegated selection under explicit rules and asked for the purchase entry. Select under those rules, explain the match, and continue to the official confirmation/payment entry without re-asking '要不要买'. Clear delegation missing only the date asks for the date alone.",
195
+ "boundaries": [
196
+ "Decide locally from the whole conversation (negations, conditions, references, latest withdrawal); no trigger-word lists, no regex, no field-completeness classifier, no mode flag, never ask the human to name a mode.",
197
+ "Explicit buyer rules outrank default recommendations; missing facts are unknown, never assumed satisfied.",
198
+ "compare must not continue into selection; prepare_checkout must not silently downgrade to waiting.",
199
+ "A pending/login/error state is never new intent; withdrawal updates the goal but never rewrites a completed payment.",
200
+ "Neither goal authorizes payment or skips confirmation: payment, terms and passenger identity stay behind their own gates."
201
+ ],
202
+ "recipe": "The Skill first screen describes query→booking→review→checkout. Load rail-fast-checkout only when the current response leaves a stage detail unclear."
190
203
  }
191
204
  }
@@ -0,0 +1,68 @@
1
+ {
2
+ "schema_version": "itp.agent_doc.v1",
3
+ "role": "buyer_agent",
4
+ "product_scope": "itpay_rail",
5
+ "topic": "rail-fast-checkout",
6
+ "title": "Rail Fast Checkout — One Human Question, Owners Do The Rest",
7
+ "purpose": "After the buyer has picked a train/seat (prepare_checkout delegation), carry one valid selection through booking review, protected checkout and issuance tracking with a single consolidated human interaction — using only existing commands and owner-issued handles.",
8
+ "when_to_use": [
9
+ "The buyer explicitly picked a train/seat or delegated selection under rules, and you hold a bookable option with booking_offer.selection_token from itpay-rail-exact or itpay-rail-smart.",
10
+ "Do NOT use for a plain schedule query (compare goal) — present one preferred option plus up to two alternatives and wait instead."
11
+ ],
12
+ "steps": [
13
+ {
14
+ "step": "create_booking_input",
15
+ "command": "printf '%s' '{\"passengers\":1,\"selection\":{\"token\":\"<token>\"}}' > /tmp/itpay-booking-input.json",
16
+ "notes": [
17
+ "Fill <token> with the current result's booking_offer.selection_token; add selection.seat_type when the buyer picked a class. Create the file yourself under /tmp — the buyer never sees a path.",
18
+ "Never reconstruct legs JSON when a token is available; the owner derives authoritative legs and re-quotes live."
19
+ ]
20
+ },
21
+ {
22
+ "step": "start_booking",
23
+ "command": "itpay services run itpay-rail-booking --input-json /tmp/itpay-booking-input.json --json",
24
+ "then": "Use the booking run response directly; call its next only if it is still waiting for progress.",
25
+ "expected_status": "booking_review_required"
26
+ },
27
+ {
28
+ "step": "one_consolidated_review",
29
+ "read": "human_action.context.review carries legs, seat_options, draft_revision and notice_version.",
30
+ "ask_human_once": "Show the itinerary, seat options and the seat-preference disclaimer in one message; collect passengers count, seat_type, one seat preference per passenger slot, party_preference, and agreement to the notice_version terms.",
31
+ "web_review": "If the review projection carries an official web entry (handoff.url in the envelope), prefer it: open it for the buyer and wait there; the chat fallback remains valid if the page is unavailable.",
32
+ "submit": "itpay services action <execution_id> --action workflow:confirm_booking --actor-type human --status approved --input-json /tmp/itpay-review.json --json",
33
+ "notes": [
34
+ "draft_revision must equal the current server value; booking_review_changed means re-read services next and resubmit, never bump locally.",
35
+ "accept_non_guaranteed is true only when the buyer actually agreed; never fabricate a human approval.",
36
+ "On booking_review_invalid, re-read this booking. A seat-code spelling correction may reuse the buyer's explicit approval only if train, actual seat class, passenger count, price, and terms are unchanged; confirm material changes or missing approval.",
37
+ "Passenger names, ID numbers and phones are NOT in this input — they belong to the protected checkout page."
38
+ ]
39
+ },
40
+ {
41
+ "step": "checkout_handoff",
42
+ "read": "The current booking response may already contain handoff.url (the protected checkout page).",
43
+ "command": "Only if the current handoff was not presented, use the exact checkout command returned by the CLI.",
44
+ "notes": [
45
+ "Show or open the checkout exactly once; pass --viewer mobile when the buyer is on a phone.",
46
+ "The buyer completes identity and payment on that page (Path B — no forced separate login). Payment handoff is not payment success; wallet return is not confirmation."
47
+ ]
48
+ },
49
+ {
50
+ "step": "track_issuance",
51
+ "command": "itpay services next <execution_id> --json",
52
+ "then": "itpay orders show <order_id> --json",
53
+ "expected_status": ["paid_pending_issuance", "issued"],
54
+ "notes": [
55
+ "Issuance is asynchronous — paid does not mean ticketed. Keep reading the SAME execution; never create a replacement.",
56
+ "On issued, report the order state; if seat details are still syncing, advise checking 12306 instead of re-querying."
57
+ ]
58
+ }
59
+ ],
60
+ "never": [
61
+ "Never build legs JSON from visible result fields while a selection_token exists.",
62
+ "Never run a second services run for the same selection — repeat submission restores the same execution.",
63
+ "Never ask for passenger identity, verification codes, or payment details in chat.",
64
+ "Never claim payment equals issuance, or a displayed handoff equals human approval.",
65
+ "Never invent command flags, action types, revisions or tokens; copy server-issued handles verbatim."
66
+ ],
67
+ "search_terms": ["fast checkout", "快速下单", "一键购票", "rail checkout recipe"]
68
+ }
@@ -13,7 +13,10 @@
13
13
 
14
14
  ```bash
15
15
  itpay checkout [--id <checkout_id>] [--token <display_token>]
16
- [--host <host>] [--target <target>] [--locale <zh-CN|en>] [--json]
16
+ [--host <host>] [--target <target>] [--locale <zh-CN|en>]
17
+ [--present <auto|browser|image|link|none>] [--no-open] [--viewer <desktop|mobile|unknown>]
18
+ [--relay-option <ref>] [--confirm-relay] [--request-key <key>] [--relay-status <relay_id>]
19
+ [--json]
17
20
  ```
18
21
 
19
22
  省略 `--id/--token` 时只能使用本机保存的一组完整句柄;不得把其他 Checkout 的 token 拼接使用。
@@ -22,6 +25,12 @@ itpay checkout [--id <checkout_id>] [--token <display_token>]
22
25
 
23
26
  `--locale` 默认 `zh-CN`,可显式使用 `en`。它只改变重新渲染的 Card 文案,不改变 Checkout、付款、授权或恢复状态。
24
27
 
28
+ 展示控制(display plane,不创建任何新的付款路径):
29
+
30
+ - `--present` 一次只选一种展示方式:`browser` 打开系统浏览器、`image` 展示官方二维码图片、`link` 给出可点击链接、`none` 只说明状态、`auto`(默认)按宿主能力选择一种。`--json` 下默认只返回展示计划而不执行打开;显式 `--present browser` 即是一次打开请求。`ITPAY_NO_BROWSER=1` 与 `--no-open` 都禁止打开浏览器。已付款、已退款或报价失效的 Checkout 不会发起任何展示动作,只读取原状态。
31
+ - `--viewer` 告知用户正在看哪类设备:`mobile` 时不会把二维码当作同机可读方式;`desktop` 时才考虑打开浏览器。
32
+ - 消息转发(relay)要求服务端已签发的脱敏联系人引用 `--relay-option`、稳定幂等键 `--request-key` 和显式人类同意 `--confirm-relay`,三者缺一不可;CLI 不自行发送短信或邮件。`--relay-status <relay_id>` 只读取一次既有转发结果,绝不重发。`--present` 与 relay 互斥。当前 Backend 未提供 relay 能力时会如实返回不可用。
33
+
25
34
  ## 等待付款输出
26
35
 
27
36
  ```json
@@ -0,0 +1,174 @@
1
+ # Agent UX Handoff — CLI Implementation Evidence
2
+
3
+ Implements the W0–W7 contract from
4
+ `compose:docs/reviews/agent-ux-handoff-2026-09-23/README.md` (PR #215,
5
+ branch `spec/agent-ux-human-handoff-20260923`). Scope: `@itpay/cli` only, plus
6
+ the one compose-side AUTH05 gate fix. No Planner V2 behavior changed; no new
7
+ payment path; protected Checkout remains the only identity/payment surface.
8
+
9
+ ## W0 — Baseline fingerprint
10
+
11
+ | Item | Value |
12
+ |---|---|
13
+ | CLI baseline commit | `760d20420ab71b7df0e61540bf5fd93543bfeb5a` |
14
+ | Package version | `@itpay/cli@2.1.17` (already published to `next`) |
15
+ | Node / npm | v24.10.0 / 11.6.1 |
16
+ | Baseline tests | 292/292 pass at baseline |
17
+ | Post-change tests | 309/309 pass (`npm test`), `npm run test:package` → `packed CLI smoke passed` |
18
+ | Coverage gate | lines 85.79 / statements 85.79 / functions 93.24 / branches 77.06 — all above thresholds (85/85/90/70) |
19
+ | Compose side | AUTH05 gate change + unit test; `go build ./...` clean, `internal/app/identity` + `internal/adapters/postgres` tests pass |
20
+
21
+ ## Contract preserved / extended
22
+
23
+ - `status`/`result`/`handoff`/`instruction`/`next`/`recovery` unchanged.
24
+ - New optional `interaction` (`itpay.interaction.v1`: `stage`, `by_goal`,
25
+ `input_template`, `recipe`) and `communication` (`itpay.communication.v1`:
26
+ `status_line`, `recommended_reason`, `human_steps`, `next_expectation`,
27
+ `must_convey`).
28
+ - All nested `itpay ` command strings inside `result`, `handoff`,
29
+ `interaction` and `communication` are qualified by `qualifyCommandsDeep`
30
+ (Agent Type + Backend URL) at envelope write time (`src/commands/guidance.ts`).
31
+ - `next` is `null` whenever no fully-bound executable exists; unresolved
32
+ values live in `input_template` with `required_input` + `executable:false`.
33
+
34
+ ## Issue → fix → verification map
35
+
36
+ ### AUTH (src/state/account_auth.ts, src/state/task_journal.ts, src/main.ts, tests/ux_envelope.test.ts)
37
+
38
+ | Issue | Fix | Test |
39
+ |---|---|---|
40
+ | AUTH01 pending compression | `stage` is reported verbatim; `email_verification_required`/`merge_confirmation_required` get distinct instructions | `AUTH01: distinct pending stages…` |
41
+ | AUTH02 completed→replaced | completed open session binds in place via stored start_token; no second auth session is created | `AUTH02: binds in place` (asserts zero POST to `/v1/dashboard/auth-sessions`) |
42
+ | AUTH03 transport→new session | poll/bind/绑定状态 transport failure → `auth_status_unknown`; saved session file preserved; no new request | `AUTH03: transport failure keeps the saved session…` |
43
+ | AUTH04 placeholder resume | `TaskJournal` (`~/.itpay-v3/tasks.<env>.json`) records paused executions; `authenticated` resumes the exact journaled command or explicitly says none exists | `AUTH04: resumes the exact journaled task` + `…says so instead of emitting a placeholder` |
44
+ | AUTH05 wallet login gate | compose: `RequireVerifiedContact` = phone OR email verified; Checkout-bound sessions keep Path B exemption | `TestDashboardWalletGateAcceptsPhoneOrEmailAndKeepsCheckoutExemption` |
45
+ | AUTH06 raw JSON output | `itpay auth login|status` goes through `writeCommandEnvelope` (qualified, human-readable non-JSON, clean `--json`) | exercised via `agentAuth` envelope assertions |
46
+ | AUTH07 status classification | `auth_session_missing` (404/410), `auth_status_unknown` (transport), `login_required` (no session), terminal states distinct (`auth_expired/denied/cancelled/failed`) | `AUTH07: server-side missing…` + `terminal auth states…` |
47
+ | SEC01 credential exposure | auth handoff marked `contains_credentials: true`; instruction forbids writing start_token into chat/logs | code review + handoff field assertion |
48
+
49
+ ### W3 services continuity (src/commands/services.ts)
50
+
51
+ | Requirement | Implementation |
52
+ |---|---|
53
+ | Two-goal semantics | `interaction.by_goal.compare` (present ≤3 options, stop) / `prepare_checkout` (select under user rules → continue to official entry, no re-ask) on `query_results_ready` |
54
+ | No fake executable next | `select`/`book`/`<rank>`/`<file>` placeholders moved to `interaction.input_template`/`booking_template` with `executable:false`; `next` holds only fully-bound commands or `null` |
55
+ | Server-issued actions verbatim | `available_actions[]` copied through untouched |
56
+ | `planning` progress state | `communication` block (status_line/next_expectation/must_convey) without selection semantics |
57
+ | Booking review | `booking_review_required` keeps legs/seat_options/draft_revision/notice_version disclosure; consumes optional `review.web_review_url|entry_url` as `handoff` when the owner projects one (W4 web-review seam); chat consolidated review remains fallback; never fabricates approval |
58
+ | Journal writes | `runServicesNext` observes every envelope into `TaskJournal` |
59
+
60
+ ### W5 presentation/relay (src/state/client_context.ts, src/commands/presentation.ts, src/render/browser.ts, src/commands/checkout.ts, src/main.ts)
61
+
62
+ | Requirement | Implementation |
63
+ |---|---|
64
+ | Context extension | `ViewerDevice`, `ExecutorLocality`, `ClientCapabilities`, `hostCapabilities()` — Agent Type is never used as viewer/executor proxy |
65
+ | Deterministic resolver | `resolvePresentation`: business blockers first (paid/refund/unknown-amount → `read_same_order`, expired quote → `recover_expired_quote`, policy → blockers); then 1 recommended + ≤2 alternatives; explicit choice honored only when feasible; failed routes never silently retried; unknown capability ≠ yes; QR never offered to the mobile device being looked at; remote executor can't open a local browser |
66
+ | `itpay checkout` flags | `--present auto\|browser\|image\|link\|none`, `--no-open`, `--viewer`, `--relay-option`, `--confirm-relay`, `--request-key`, `--relay-status`; present⊥relay mutually exclusive; `--json` plans without side effects, explicit `--present browser` is the display request |
67
+ | Browser opener | argv-array spawn (no shell), HTTPS official origins only, loopback only behind `ITPAY_CLI_DEV=1`, no userinfo/arbitrary schemes; `dispatch_accepted` ≠ `page_loaded` |
68
+ | Relay | `resolveRelay` enforces issued-option + request-key + explicit consent; backend capability absent → honest `unavailable`; `--relay-status` read-only |
69
+
70
+ ## Evidence separation (per W0/W7)
71
+
72
+ | Class | Count | Where |
73
+ |---|---|---|
74
+ | In-process unit/contract tests (new) | 17 | `tests/ux_envelope.test.ts` |
75
+ | Existing suite updated to new contract | 9 assertions moved to `interaction.*` | `tests/smoke.test.ts` |
76
+ | Mock backend extended | auth sessions + binding endpoints + failure injection | `tests/mock_backend.ts` |
77
+ | Packed-package smoke | pass | `npm run test:package` |
78
+
79
+ ## NOT executed — remains open for acceptance
80
+
81
+ - No target-agent (Astra/harness) runs against real models.
82
+ - No real browser/native-host display verification — only `isPresentableURL` +
83
+ argv construction are unit-tested; `openInSystemBrowser` spawn is not e2e.
84
+ - No SMS/email/wallet relay test — backend relay endpoints do not exist; CLI
85
+ returns honest `unavailable`.
86
+ - No payment/issuance/refund flow test — those owners were untouched.
87
+ - Tarball-installed CLI behavior verified only by `scripts/package-smoke.mjs`,
88
+ not by a manual install run.
89
+ - The 72-check matrix from `VALIDATION.json` maps to implementation status
90
+ here; actual model/host acceptance rows stay "not executed" — this document
91
+ does not claim them.
92
+
93
+ ---
94
+
95
+ ## Addendum — 09 Checkout Lifecycle & UX Repair (compose `codex/checkout-lifecycle-ux`, base `59f95a1`)
96
+
97
+ Spec: `09-CHECKOUT-LIFECYCLE-AND-UX-REPAIR-20260927.md`. Implemented the
98
+ server-owned payment deadline, checkout-scoped phone reuse, in-page desktop QR,
99
+ and shared deadline projection; CLI consumes the same fields.
100
+
101
+ ### Single payable deadline D (backend)
102
+
103
+ | Requirement | Implementation | Test |
104
+ |---|---|---|
105
+ | One deadline everywhere | `checkouts.expires_at` = `min(created_at + PaymentTTL(15m), earliest bound quote-lock expiry)` set in `CreateCheckout`; copied verbatim to `orders.payment_deadline_at` by `EnsurePendingOrderForAuthorizedCheckout`; legacy rows fall back to `PaymentDeadline(created_at, ttl, quote expiry)`, never re-anchored | `TestLifecycleSingleDeadlineRealPG`, `TestLifecycleEarlyQuoteWinsDeadlineRealPG` (real PG) |
106
+ | Refresh never extends | persisted `expires_at` wins; reused intents keep their expiry | same test: second intent call returns identical `expires_at`, zero extra provider calls |
107
+ | Expiry truly cancels | `ExpireStalePendingOrders` single-tx cascade: order→cancelled, checkout→expired, intents→expired, quote locks→expired, bindings→expired, executions→cancelled+event, display tokens→expired, auth sessions→expired; `FOR UPDATE SKIP LOCKED`; orphan executions swept only when no live payment path remains | `TestLifecycleExpirySweepCascadeRealPG`: pre-D no-op, post-D full cascade, repeat sweep idempotent |
108
+ | Paid protected | candidate scan requires `status='pending_payment'`; conditional UPDATE loses the race to a verified payment | `TestLifecyclePaidSurvivesAndLateCallbackReconcilesRealPG` |
109
+ | Late provider funds | `CompleteProviderPayment` returns `Reconciled:true`, persists funds evidence, never resurrects cancelled order | same test: late TRADE_SUCCESS after sweep → reconciled, order stays `cancelled` |
110
+ | New actions refused at D | `ErrPaymentWindowClosed` → HTTP 410 `payment_window_closed`; `<1min` remaining refuses new QR/wallet actions; `paymentActionExpiry = min(now+actionTTL, D)` clamped into provider `TimeoutExpress`/`time_expire` | unit tests in `internal/app/payment`, `internal/httpapi/handlers`; PG test asserts intent `expires_at = D` |
111
+
112
+ ### Phone (checkout-scoped, privacy-preserving)
113
+
114
+ - `ports.BuyerProfile` exposes `LoginPhone`/`PhoneVerified`; presentation projects
115
+ `account_phone_option` = `{source, masked_recipient}` only — the raw number
116
+ never leaves the server.
117
+ - `order_contacts` rows are checkout-scoped: `InsertAccountOrderContact` copies
118
+ the verified account phone under a row lock (refused on expired/terminal
119
+ checkout, `ErrOrderContactLocked` once a payment action exists);
120
+ `GetVerifiedOrderContact` feeds `RequireDeliveryContact` and the masked
121
+ `verified_sms_contact` projection.
122
+ - Replacement phone = OTP-verified `order_contact` challenge; it never writes
123
+ the account login phone. `UpdateDeliveryContact` gates `phone_source:
124
+ account_phone` on a live buyer session and reads the account-side verified
125
+ number server-side — client-submitted digits are not trusted.
126
+ - Real-PG tests: `TestLifecycleOrderContactScopeRealPG` covers reuse
127
+ roundtrip, cross-checkout isolation, upsert versioning, post-payment lock,
128
+ expired-checkout refusal.
129
+
130
+ ### Shared projection & clients
131
+
132
+ - `CheckoutPresentation`/`Order`/buyer summaries emit `payment_deadline_at`,
133
+ `server_now`, `payment_remaining_seconds`; expired pending orders project
134
+ `cancelled` and drop the deadline field.
135
+ - Web: `paymentCountdown.ts` ticks from server values only (never extends);
136
+ `PaymentDeadlineBadge` shared by OrdersPage/OrderDetailPage; CheckoutPage
137
+ disables payment at zero and re-reads the backend; desktop Alipay QR renders
138
+ in-page via `qrcode` with the copy "展示付款码不会直接扣款".
139
+ - CLI: `itpay checkout`/`itpay order` envelopes carry the same deadline fields;
140
+ pending instructions state the exact deadline and that refresh does not
141
+ extend it; expiry guidance routes to re-quote rather than replaying stale
142
+ payment actions. `Order`/`CheckoutPresentation` types extended in
143
+ `src/client/types.ts`.
144
+
145
+ ### Harness fix (per §6)
146
+
147
+ - `ux_harness.py` interventions now classify `human_product_step` (login/OTP/
148
+ passenger confirmation/QR scan — requires step+operator+scope+a same-order
149
+ backend_observation in `verification_event_ids`) vs `engineering_rescue`
150
+ (still fail); unclassified legacy entries stay conservative.
151
+ - `relative_file` no longer rejects runs under symlinked tempdirs (macOS
152
+ `/var`→`/private/var`); symlink checks now apply inside the run dir only.
153
+ - Prompts updated (UI-OPERATOR/USER-SIMULATOR/ASTRA-REVIEWER/README).
154
+ - `test_harness.py` 25/25, including 6 new classification cases.
155
+
156
+ ### Actually executed (this round)
157
+
158
+ - Backend unit: `go test ./internal/app/{checkout,payment,order,railbooking,identity} ./internal/presenter ./internal/httpapi/handlers` — all green.
159
+ - Real PG (`ITPAY_V3_TEST_DATABASE_URL`, isolated per-test DB): 6 new
160
+ lifecycle tests + golden regression suite (`SearchSelectCheckoutGoldenFlow`,
161
+ receipt aggregation, provider-failure, quota transition) — all green.
162
+ - Web: `npm run typecheck` clean; `npm test` 73/73.
163
+ - CLI: 309/309 tests (240 smoke + 69 unit), tarball package smoke previously
164
+ green; contract/package `tsc` clean.
165
+ - Harness: `python3 -m unittest test_harness` 25/25.
166
+
167
+ ### NOT executed (unchanged honesty)
168
+
169
+ - No real Alipay/WeChat channel call — fake `AlipayPaymentClient` records
170
+ requests only; provider sandbox validation remains open.
171
+ - No real browser e2e of the QR modal / countdown; no real-device OTP.
172
+ - No deployed dev-environment verification of the new checkout path.
173
+ - Harness changes are structural (classifier + fixtures); no real triadic run
174
+ was executed.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@itpay/cli",
3
- "version": "2.1.17",
3
+ "version": "2.1.19",
4
4
  "description": "The ItPay CLI for services, orders, and human-authorized purchased content.",
5
5
  "type": "module",
6
6
  "bin": {