@itpay/cli 2.1.17 → 2.1.18

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.
@@ -3,6 +3,8 @@ import { homedir } from 'node:os';
3
3
  import { dirname, resolve } from 'node:path';
4
4
  import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync, rmSync } from 'node:fs';
5
5
  import { writeLocalPNG } from '../render/qr.js';
6
+ import { TaskJournal } from './task_journal.js';
7
+ import { taskJournalPath } from './config.js';
6
8
  export function sellerAuthPath(baseURL, env = process.env, purpose = "seller") {
7
9
  return resolve(env.HOME || homedir(), '.itpay-v3', `${purpose}-${createHash('sha256').update(baseURL).digest('hex').slice(0, 16)}.json`);
8
10
  }
@@ -32,46 +34,176 @@ export function sellerAuth(action, baseURL, env = process.env, fetcher = fetch)
32
34
  export function agentAuth(action, baseURL, backend, env = process.env, fetcher = fetch) {
33
35
  return accountAuth(action, baseURL, env, fetcher, backend);
34
36
  }
37
+ // AUTH01: dashboard session stages stay distinct — the human must know exactly
38
+ // what the official page still needs instead of a generic "pending".
39
+ const ACTIVE_SESSION_STAGES = ['created', 'waiting_provider', 'email_verification_required', 'merge_confirmation_required'];
40
+ function stageInstruction(stage) {
41
+ if (stage === 'email_verification_required') {
42
+ return '钱包授权已完成,官方页还需要完成联系方式验证(邮箱或手机,以页面实际提供的方式为准)。完成后继续原查询,不用重说行程。';
43
+ }
44
+ if (stage === 'merge_confirmation_required') {
45
+ return '官方页需要用户确认账号合并决定后才能完成登录;不要替用户选择,也不要新建授权请求。';
46
+ }
47
+ return '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。';
48
+ }
49
+ // AUTH07: distinct outcomes stay distinct — a technical failure is never
50
+ // reported as a user denial, and a missing session is its own state.
51
+ const TERMINAL_SESSION_STATES = {
52
+ expired: {
53
+ status: 'auth_expired',
54
+ instruction: '授权请求已过期。重新运行 itpay auth login 生成新请求;之前的查询输入在服务端保留,可恢复。',
55
+ },
56
+ failed: {
57
+ status: 'auth_failed',
58
+ instruction: '官方授权因技术原因未完成(不是用户拒绝)。重新运行 itpay auth login 生成新请求。',
59
+ },
60
+ cancelled: {
61
+ status: 'auth_cancelled',
62
+ instruction: '用户在官方页取消了授权。如仍需登录,重新运行 itpay auth login。',
63
+ },
64
+ denied: {
65
+ status: 'auth_denied',
66
+ instruction: '官方授权被拒绝。重新运行 itpay auth login 生成新请求。',
67
+ },
68
+ };
69
+ class AuthRequestError extends Error {
70
+ httpStatus;
71
+ constructor(httpStatus, message) {
72
+ super(message);
73
+ this.httpStatus = httpStatus;
74
+ this.name = 'AuthRequestError';
75
+ }
76
+ }
77
+ // AUTH03: transport/read uncertainty never maps to "denied" or a new session —
78
+ // the stored session stays on disk and the caller retries the same poll.
79
+ function authStatusUnknown(sessionID) {
80
+ return {
81
+ status: 'auth_status_unknown',
82
+ result: sessionID ? { dashboard_auth_session_id: sessionID } : {},
83
+ instruction: '读取官方授权状态时遇到网络或服务端临时故障;原授权请求已保留。稍后重读同一状态,不要新建授权、不要清除本地登记。',
84
+ next: { command: 'itpay auth status --json', reason: '稍后重读同一授权状态', poll_after_ms: 5000 },
85
+ recovery: [],
86
+ };
87
+ }
88
+ function sessionGone(env, purpose, baseURL, sessionID) {
89
+ rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
90
+ return {
91
+ status: 'auth_session_missing',
92
+ result: sessionID ? { dashboard_auth_session_id: sessionID } : {},
93
+ instruction: '之前的授权请求在服务端已不存在。重新运行 itpay auth login 生成新请求。',
94
+ next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
95
+ recovery: [],
96
+ };
97
+ }
98
+ // AUTH04: a successful bind resumes the SAME task that paused for login —
99
+ // from the local task journal, never a placeholder and never a guess at the
100
+ // latest order.
101
+ function authenticatedEnvelope(env, phoneVerified) {
102
+ const paused = new TaskJournal(taskJournalPath(env)).pausedTasks()
103
+ .filter((task) => task.stage === 'login_required' || task.stage === 'quota_paused');
104
+ const resumable = paused.filter((task) => typeof task.resume_command === 'string');
105
+ const result = { bound: true, phone_verified: phoneVerified };
106
+ let instruction = '登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。';
107
+ let next = null;
108
+ if (resumable.length === 1) {
109
+ next = { command: resumable[0].resume_command, reason: '恢复登录前暂停的同一查询' };
110
+ }
111
+ else if (resumable.length > 1) {
112
+ result.paused_executions = resumable.map((task) => ({
113
+ service_execution_id: task.service_execution_id,
114
+ ...(task.service_id ? { service_id: task.service_id } : {}),
115
+ resume_command: task.resume_command,
116
+ }));
117
+ instruction += ' 有多个暂停的查询:只恢复与当前用户目标对应的那一个,不要一次恢复全部。';
118
+ }
119
+ else {
120
+ instruction += ' 当前没有待恢复的暂停查询;不构造恢复命令。';
121
+ }
122
+ return { status: 'authenticated', result, instruction, next, recovery: [] };
123
+ }
35
124
  async function accountAuth(action, baseURL, env, fetcher, backend) {
36
125
  const purpose = backend ? 'agent-login' : 'seller';
37
126
  const command = backend ? 'itpay auth status' : 'itpay sell auth status';
38
127
  if (backend) {
39
- const current = await backend.agentAccountStatus();
40
- if (current.status === 'authenticated')
41
- return {
42
- status: 'authenticated',
43
- result: { bound: true, phone_verified: current.phone_verified === true },
44
- instruction: '设备已绑定账号,可继续之前的查询或购买;登录不附带任何额外授权。',
45
- next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原执行' },
46
- recovery: [],
47
- };
128
+ try {
129
+ const current = await backend.agentAccountStatus();
130
+ if (current.status === 'authenticated')
131
+ return authenticatedEnvelope(env, current.phone_verified === true);
132
+ }
133
+ catch (error) {
134
+ if (error instanceof AuthRequestError)
135
+ throw error;
136
+ // AUTH03: even the binding-status probe can hit transport failure; the
137
+ // truthful state is "unknown", never an implicit unbound or a fresh login.
138
+ return authStatusUnknown(undefined);
139
+ }
48
140
  }
49
141
  async function request(path, init = {}) {
50
142
  const response = await fetcher(baseURL + path, { ...init, redirect: 'error', signal: AbortSignal.timeout(15000) });
51
143
  if (!response.ok)
52
- throw new Error(`ItPay authorization failed (${response.status}); retry login if expired`);
144
+ throw new AuthRequestError(response.status, `ItPay authorization failed (${response.status}); retry login if expired`);
53
145
  return response;
54
146
  }
147
+ function pollOpenSession(state) {
148
+ return request(`/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}?poll_token=${encodeURIComponent(state.pollToken)}`)
149
+ .then((response) => response.json());
150
+ }
151
+ async function bindSavedSession(state) {
152
+ const bound = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
153
+ if (bound.status !== 'authenticated')
154
+ throw new Error('Agent binding did not complete');
155
+ rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
156
+ return authenticatedEnvelope(env, bound.phone_verified === true);
157
+ }
55
158
  if (action === 'login') {
56
159
  if (backend) {
57
160
  const open = read(baseURL, env, purpose);
58
161
  if (open?.sessionID && open.pollToken && open.startToken) {
59
162
  try {
60
- const current = await (await request(`/v1/dashboard/auth-sessions/${encodeURIComponent(open.sessionID)}?poll_token=${encodeURIComponent(open.pollToken)}`)).json();
61
- if (['created', 'waiting_provider', 'email_verification_required', 'merge_confirmation_required'].includes(current.status)) {
163
+ const current = await pollOpenSession(open);
164
+ // AUTH02: a completed-but-unbound session binds in place; never
165
+ // create a second auth session for the same login.
166
+ if (current.status === 'completed')
167
+ return await bindSavedSession(open);
168
+ if (ACTIVE_SESSION_STAGES.includes(current.status)) {
62
169
  const url = open.authURL ?? '';
63
170
  const qr = url ? await writeLocalPNG(url).catch(() => undefined) : undefined;
64
171
  return {
65
172
  status: 'auth_pending',
66
- result: { dashboard_auth_session_id: open.sessionID, expires_at: current.expires_at, methods: ['phone', 'email', 'alipay', 'wechat'] },
67
- handoff: { url, ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}) },
68
- instruction: '已有进行中的官方授权请求,沿用同一链接或二维码;不要生成新请求。用户在页面内选择手机号验证码、邮箱或钱包完成登录。',
173
+ result: {
174
+ dashboard_auth_session_id: open.sessionID,
175
+ stage: current.status,
176
+ expires_at: current.expires_at,
177
+ methods: ['phone', 'email', 'alipay', 'wechat'],
178
+ },
179
+ // SEC01: the handoff URL carries one-time credentials — it goes
180
+ // to the human only, never into logs, prompts, or PR material.
181
+ handoff: {
182
+ url,
183
+ contains_credentials: true,
184
+ ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}),
185
+ },
186
+ instruction: `${stageInstruction(current.status)} 沿用同一链接或二维码;不要生成新请求。`,
69
187
  next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
70
188
  recovery: [],
71
189
  };
72
190
  }
191
+ const terminal = TERMINAL_SESSION_STATES[current.status];
192
+ if (terminal) {
193
+ rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
194
+ // Fall through to a fresh session only after a definitive
195
+ // terminal state; anything else keeps the stored session.
196
+ }
197
+ else {
198
+ return authStatusUnknown(open.sessionID);
199
+ }
200
+ }
201
+ catch (error) {
202
+ if (error instanceof AuthRequestError && (error.httpStatus === 404 || error.httpStatus === 410)) {
203
+ return sessionGone(env, purpose, baseURL, open.sessionID);
204
+ }
205
+ return authStatusUnknown(open.sessionID);
73
206
  }
74
- catch { /* fall through to a fresh request */ }
75
207
  }
76
208
  }
77
209
  const response = await request('/v1/dashboard/auth-sessions', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ return_to: backend ? '/' : '/seller' }) });
@@ -95,14 +227,16 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
95
227
  status: 'auth_pending',
96
228
  result: {
97
229
  dashboard_auth_session_id: result.dashboard_auth_session_id,
230
+ stage: 'created',
98
231
  expires_at: result.expires_at,
99
232
  methods: ['phone', 'email', 'alipay', 'wechat'],
100
233
  },
101
234
  handoff: {
102
235
  url: url.href,
236
+ contains_credentials: true,
103
237
  ...(qr ? { qr_local_path: qr.filePath, markdown: `![ItPay 官方授权二维码](${qr.filePath})` } : {}),
104
238
  },
105
- instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token 不要写入聊天记录。',
239
+ instruction: '把官方授权页或二维码交给用户。用户在页面内选择手机号验证码、邮箱或钱包完成登录;不要替用户输入手机号或验证码,start_token 不要写入聊天记录或日志。',
106
240
  next: { command: 'itpay auth status --json', reason: '用户完成页面登录后确认绑定', poll_after_ms: 5000 },
107
241
  recovery: [],
108
242
  };
@@ -119,36 +253,50 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
119
253
  }
120
254
  if (!backend && sellerSessionToken(baseURL, env))
121
255
  return { status: 'authenticated', base_url: baseURL, expires_at: state?.expiresAt };
122
- if (!state?.sessionID || !state.pollToken || !state.startToken)
123
- return { status: 'login_required' };
124
- const path = `/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}`;
125
- const progress = await (await request(`${path}?poll_token=${encodeURIComponent(state.pollToken)}`)).json();
256
+ if (!state?.sessionID || !state.pollToken || !state.startToken) {
257
+ // AUTH07: a bare `status` with no session is its own truthful state, not a
258
+ // failure and not an implicit login.
259
+ if (!backend)
260
+ return { status: 'login_required' };
261
+ return {
262
+ status: 'login_required',
263
+ result: {},
264
+ instruction: '当前设备尚未绑定账号,也没有进行中的授权请求。需要时运行 itpay auth login 发起官方登录。',
265
+ next: { command: 'itpay auth login --json', reason: '需要账号绑定时发起官方授权' },
266
+ recovery: [],
267
+ };
268
+ }
269
+ let progress;
270
+ try {
271
+ progress = await pollOpenSession(state);
272
+ }
273
+ catch (error) {
274
+ if (error instanceof AuthRequestError && (error.httpStatus === 404 || error.httpStatus === 410)) {
275
+ return sessionGone(env, purpose, baseURL, state.sessionID);
276
+ }
277
+ return authStatusUnknown(state.sessionID);
278
+ }
126
279
  if (progress.status !== 'completed') {
127
280
  if (backend) {
128
- if (progress.status === 'expired') {
281
+ const terminal = TERMINAL_SESSION_STATES[progress.status];
282
+ if (terminal) {
129
283
  rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
130
284
  return {
131
- status: 'auth_expired',
132
- result: { dashboard_auth_session_id: state.sessionID },
133
- instruction: '授权请求已过期。重新运行 itpay auth login 生成新请求;之前的查询输入在服务端保留,可恢复。',
134
- next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
135
- recovery: [],
136
- };
137
- }
138
- if (progress.status === 'failed' || progress.status === 'cancelled' || progress.status === 'denied') {
139
- rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
140
- return {
141
- status: progress.status === 'failed' ? 'auth_denied' : `auth_${progress.status}`,
142
- result: { dashboard_auth_session_id: state.sessionID },
143
- instruction: '官方授权未通过或被取消。重新运行 itpay auth login 生成新请求。',
285
+ status: terminal.status,
286
+ result: { dashboard_auth_session_id: state.sessionID, stage: progress.status },
287
+ instruction: terminal.instruction,
144
288
  next: { command: 'itpay auth login --json', reason: '重新发起官方授权' },
145
289
  recovery: [],
146
290
  };
147
291
  }
148
292
  return {
149
293
  status: 'auth_pending',
150
- result: { dashboard_auth_session_id: state.sessionID },
151
- instruction: '用户仍在官方页面完成登录;保留当前 handoff,不要生成新二维码或新请求。',
294
+ result: {
295
+ dashboard_auth_session_id: state.sessionID,
296
+ stage: progress.status,
297
+ ...(progress.expires_at ? { expires_at: progress.expires_at } : {}),
298
+ },
299
+ instruction: stageInstruction(progress.status),
152
300
  next: { command: 'itpay auth status --json', reason: '轮询同一授权请求', poll_after_ms: 5000 },
153
301
  recovery: [],
154
302
  };
@@ -156,19 +304,18 @@ async function accountAuth(action, baseURL, env, fetcher, backend) {
156
304
  return { status: progress.status, instruction: 'Finish login and email verification in the browser.' };
157
305
  }
158
306
  if (backend) {
159
- const result = await backend.bindAgentAccount({ dashboard_auth_session_id: state.sessionID, start_token: state.startToken });
160
- if (result.status !== 'authenticated')
161
- throw new Error('Agent binding did not complete');
162
- rmSync(sellerAuthPath(baseURL, env, purpose), { force: true });
163
- return {
164
- status: 'authenticated',
165
- result: { bound: true, phone_verified: result.phone_verified === true },
166
- instruction: '登录与绑定已完成,告知用户可继续之前的查询;已注册账号查询不消耗试用次数,仍受正常限流。',
167
- next: { command: 'itpay services run <service_id> --execution <pending_execution_id> --json', reason: '恢复被额度暂停的原查询' },
168
- recovery: [],
169
- };
307
+ try {
308
+ return await bindSavedSession(state);
309
+ }
310
+ catch (error) {
311
+ if (error instanceof AuthRequestError)
312
+ throw error;
313
+ // Binding outcome unknown: keep the session so a retry can still bind
314
+ // the same completed login instead of creating a new one.
315
+ return authStatusUnknown(state.sessionID);
316
+ }
170
317
  }
171
- const claimed = await request(`${path}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
318
+ const claimed = await request(`/v1/dashboard/auth-sessions/${encodeURIComponent(state.sessionID)}/claim?start_token=${encodeURIComponent(state.startToken)}`, { method: 'POST' });
172
319
  const token = /(?:^|[, ]+)itpay_buyer_session=([^;]+)/.exec(claimed.headers.get('set-cookie') ?? '')?.[1];
173
320
  const session = await claimed.json();
174
321
  if (!token || !session.expires_at || !(Date.parse(session.expires_at) > Date.now()))
@@ -79,3 +79,73 @@ export function validateContext(host, target) {
79
79
  }
80
80
  return undefined;
81
81
  }
82
+ export function normalizeViewer(raw) {
83
+ const v = raw?.trim().toLowerCase();
84
+ if (v === "desktop" || v === "mobile")
85
+ return v;
86
+ return "unknown";
87
+ }
88
+ export function normalizeLocality(raw) {
89
+ const v = raw?.trim().toLowerCase();
90
+ if (v === "same_device" || v === "remote" || v === "cloud")
91
+ return v;
92
+ return "unknown";
93
+ }
94
+ // Conservative declared capability table per host. Terminal/desktop agent
95
+ // hosts run where a human watches a screen and a system browser exists;
96
+ // IM hosts can send links/buttons but cannot be assumed to open pages;
97
+ // plain-chat surfaces get links and copyable entries only.
98
+ export function hostCapabilities(host) {
99
+ switch (host) {
100
+ case "terminal":
101
+ case "codex":
102
+ case "claude-code":
103
+ return {
104
+ user_visible_browser: "unknown",
105
+ system_browser: "yes",
106
+ clickable_https: "yes",
107
+ image_visible: host === "terminal" ? "no" : "unknown",
108
+ native_url_button: "no",
109
+ user_visible_terminal: "yes",
110
+ other_device_scan: "yes",
111
+ send_message: "yes",
112
+ };
113
+ case "telegram":
114
+ case "feishu":
115
+ case "lark":
116
+ return {
117
+ user_visible_browser: "no",
118
+ system_browser: "no",
119
+ clickable_https: "yes",
120
+ image_visible: "yes",
121
+ native_url_button: "yes",
122
+ user_visible_terminal: "no",
123
+ other_device_scan: "unknown",
124
+ send_message: "yes",
125
+ };
126
+ case "discord":
127
+ case "whatsapp":
128
+ return {
129
+ user_visible_browser: "no",
130
+ system_browser: "no",
131
+ clickable_https: "yes",
132
+ image_visible: "yes",
133
+ native_url_button: "unknown",
134
+ user_visible_terminal: "no",
135
+ other_device_scan: "unknown",
136
+ send_message: "yes",
137
+ };
138
+ case "plain-chat":
139
+ default:
140
+ return {
141
+ user_visible_browser: "no",
142
+ system_browser: "unknown",
143
+ clickable_https: "yes",
144
+ image_visible: "no",
145
+ native_url_button: "unknown",
146
+ user_visible_terminal: "no",
147
+ other_device_scan: "unknown",
148
+ send_message: "yes",
149
+ };
150
+ }
151
+ }
@@ -15,11 +15,12 @@ import { OperationJournal } from "./operation_journal.js";
15
15
  export const DEFAULT_BASE_URL = "https://app.itpay.ai";
16
16
  export const DEV_BASE_URL = "https://dev.itpay.ai";
17
17
  export const SANDBOX_BASE_URL = "https://sandbox.itpay.ai";
18
- export const CLI_VERSION = "2.1.17";
19
- export const API_CONTRACT_REVISION = "sha256:c6125896053540d1b56f9d106df499ac90162fb8fa4cce619c8029e90f771f85";
18
+ export const CLI_VERSION = "2.1.18";
19
+ export const API_CONTRACT_REVISION = "sha256:2f9fbcdc5cd0630910abb52671304cc019e348e9a778017ec92ddc3513b3639f";
20
20
  const CART_SESSION_DEFAULT_DIR = ".itpay-v3";
21
21
  const CART_SESSION_FILENAME = "cart.json";
22
22
  const OPERATION_JOURNAL_FILENAME = "operations.json";
23
+ const TASK_JOURNAL_FILENAME = "tasks.json";
23
24
  export function cliDistribution(env = process.env) {
24
25
  if (env.ITPAY_DISTRIBUTION === "openclaw-skill-bundle")
25
26
  return "openclaw-skill-bundle";
@@ -75,6 +76,16 @@ export function cartSessionPath(env = process.env) {
75
76
  mkdirSync(dir, { recursive: true });
76
77
  return resolve(dir, stateFilename(CART_SESSION_FILENAME, resolveBackendURL(env)));
77
78
  }
79
+ // Task journal (AUTH04): paused Service Executions per backend, so a login
80
+ // completion can resume the SAME task instead of guessing from the latest order.
81
+ export function taskJournalPath(env = process.env) {
82
+ if (env.ITPAY_TASK_JOURNAL_PATH) {
83
+ return resolve(env.ITPAY_TASK_JOURNAL_PATH);
84
+ }
85
+ const dir = stateDir(env);
86
+ mkdirSync(dir, { recursive: true });
87
+ return resolve(dir, stateFilename(TASK_JOURNAL_FILENAME, resolveBackendURL(env)));
88
+ }
78
89
  export function loadConfig(env = process.env) {
79
90
  const baseURL = resolveBackendURL(env);
80
91
  const bearerToken = env.ITPAY_BEARER_TOKEN || undefined;
@@ -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
  }
@@ -13,7 +13,7 @@
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 the buyer knows BOTH the exact departure and arrival railway station names (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 use it with only a city or fuzzy location.",
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
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.",
19
19
  "svc_qizhidao_company_lookup": "Not a railway service; do not mix intents."
@@ -67,7 +67,7 @@
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
  }
@@ -92,6 +92,7 @@
92
92
  "Submit input with services run <service> --execution <id> --input-json <file> --json on the SAME execution.",
93
93
  "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
94
  "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.",
95
+ "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
96
  "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
97
  "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
98
  "Payment success does not mean the ticket is issued. Issuance is asynchronous; keep reading the same execution with services next.",
@@ -122,6 +123,7 @@
122
123
  },
123
124
  "agent_rules": [
124
125
  "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.",
126
+ "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
127
  "Never invent input field names. If the guidance block or this doc does not list a field, do not send it.",
126
128
  "Never ask the buyer for passenger names, ID numbers, or phone numbers in chat; those are collected only on the protected checkout page.",
127
129
  "Never reconstruct provider-shaped legs JSON when a booking_offer.selection_token is available; submit the token plus passengers.",
@@ -187,5 +189,17 @@
187
189
  "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
190
  "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
191
  "honesty": "Coverage is bounded: repeat searched_scope/notices verbatim, never claim national-exhaustive search, and surface missing prices as unknown — never 0."
192
+ },
193
+ "goals": {
194
+ "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.",
195
+ "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.",
196
+ "boundaries": [
197
+ "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.",
198
+ "Explicit buyer rules outrank default recommendations; missing facts are unknown, never assumed satisfied.",
199
+ "compare must not continue into selection; prepare_checkout must not silently downgrade to waiting.",
200
+ "A pending/login/error state is never new intent; withdrawal updates the goal but never rewrites a completed payment.",
201
+ "Neither goal authorizes payment or skips confirmation: payment, terms and passenger identity stay behind their own gates."
202
+ ],
203
+ "recipe": "For the query→booking→review→checkout continuity after a valid selection, load the rail-fast-checkout topic (itpay docs show rail-fast-checkout --json)."
190
204
  }
191
205
  }