@remixmate/cli 0.9.35 → 0.9.36

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.
@@ -1,4 +1,24 @@
1
1
  export declare function traceHeaders(): Record<string, string>;
2
- export declare function cleanDiagnosticText(value: string): string;
2
+ /**
3
+ * Redact a string for anything that leaves this process (diagnostic lines,
4
+ * execution traces).
5
+ *
6
+ * Kept in lockstep with ab-agent's `lib/telemetry.cleanText` — the two feed the
7
+ * same `media_chat_turn_trace` table, and a table whose rows were sanitized by
8
+ * two different rulesets is a table nobody can reason about. ANSI stripping,
9
+ * e-mail and phone redaction were added here to close that gap.
10
+ *
11
+ * `max` caps the result in BYTES, keeping the head. Callers that need the tail
12
+ * (a command's decisive line is its last one) slice before calling.
13
+ */
14
+ export declare function cleanDiagnosticText(value: string, max?: number): string;
15
+ /**
16
+ * Exit code → the outcome vocabulary of the logging contract.
17
+ *
18
+ * Exported because the execution trace (project/trace.ts) reports the same
19
+ * verdict for the same run: two mappings would let a run be `failure` in the
20
+ * diagnostic line and `success` in the database.
21
+ */
22
+ export declare function outcomeOf(exitCode: number): 'success' | 'cancelled' | 'rejected' | 'failure';
3
23
  /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
4
24
  export declare function diagnostic(exitCode: number, durationMs: number, error?: unknown): void;
@@ -8,8 +8,22 @@ export function traceHeaders() {
8
8
  process.env.AB_OPERATION_ID ||= randomUUID();
9
9
  return { traceparent: header };
10
10
  }
11
- export function cleanDiagnosticText(value) {
12
- let text = value.replace(/https?:\/\/[^\s"'<>]+/g, raw => {
11
+ /**
12
+ * Redact a string for anything that leaves this process (diagnostic lines,
13
+ * execution traces).
14
+ *
15
+ * Kept in lockstep with ab-agent's `lib/telemetry.cleanText` — the two feed the
16
+ * same `media_chat_turn_trace` table, and a table whose rows were sanitized by
17
+ * two different rulesets is a table nobody can reason about. ANSI stripping,
18
+ * e-mail and phone redaction were added here to close that gap.
19
+ *
20
+ * `max` caps the result in BYTES, keeping the head. Callers that need the tail
21
+ * (a command's decisive line is its last one) slice before calling.
22
+ */
23
+ export function cleanDiagnosticText(value, max = 2048) {
24
+ let text = value
25
+ .replace(/\x1b\[[0-9;]*m/g, '')
26
+ .replace(/https?:\/\/[^\s"'<>]+/g, raw => {
13
27
  try {
14
28
  const url = new URL(raw);
15
29
  return url.origin + url.pathname;
@@ -17,19 +31,36 @@ export function cleanDiagnosticText(value) {
17
31
  catch {
18
32
  return '[url]';
19
33
  }
20
- }).replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]');
34
+ })
35
+ .replace(/(Bearer\s+|(?:token|secret|password|api[_-]?key)\s*[=:]\s*)[^\s,;}]+/gi, '$1[redacted]')
36
+ .replace(/[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}/gi, '[email]')
37
+ .replace(/\b1[3-9]\d{9}\b/g, '[phone]');
21
38
  for (const [key, secret] of Object.entries(process.env)) {
22
39
  if (/TOKEN|SECRET|PASSWORD|API_KEY/i.test(key) && secret && secret.length >= 6)
23
40
  text = text.split(secret).join('[redacted]');
24
41
  }
25
- return Buffer.from(text).subarray(0, 2048).toString('utf8');
42
+ return Buffer.from(text).subarray(0, max).toString('utf8');
43
+ }
44
+ /**
45
+ * Exit code → the outcome vocabulary of the logging contract.
46
+ *
47
+ * Exported because the execution trace (project/trace.ts) reports the same
48
+ * verdict for the same run: two mappings would let a run be `failure` in the
49
+ * diagnostic line and `success` in the database.
50
+ */
51
+ export function outcomeOf(exitCode) {
52
+ if (exitCode === 0)
53
+ return 'success';
54
+ if (exitCode === 130)
55
+ return 'cancelled';
56
+ return [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
26
57
  }
27
58
  /** Opt-in line framing for hosts; stdout is exclusively the existing skill protocol. */
28
59
  export function diagnostic(exitCode, durationMs, error) {
29
60
  if (process.env.AB_DIAGNOSTICS !== '1')
30
61
  return;
31
62
  traceHeaders();
32
- const outcome = exitCode === 0 ? 'success' : exitCode === 130 ? 'cancelled' : [2, 3, 4].includes(exitCode) ? 'rejected' : 'failure';
63
+ const outcome = outcomeOf(exitCode);
33
64
  const record = { schema_version: 1, timestamp: new Date().toISOString(), service: 'remixmate-cli', environment: process.env.APP_ENV ?? 'dev',
34
65
  level: outcome === 'failure' ? 'error' : 'info', event: 'cli.execution.completed', message: 'CLI execution completed', outcome,
35
66
  trace_id: process.env.AB_TRACEPARENT?.split('-')[1], operation_id: process.env.AB_OPERATION_ID,
package/dist/install.d.ts CHANGED
@@ -37,6 +37,7 @@ export declare function detectHosts(): Host[];
37
37
  * 完全看不到它,只会以为这个 CLI 不支持 —— 正是这次 WorkBuddy 的翻车方式。
38
38
  */
39
39
  export declare function projectOnlyHosts(): HostId[];
40
+ export declare function packageVersion(): string;
40
41
  export declare function installUsage(): void;
41
42
  export declare function uninstallUsage(): void;
42
43
  export declare function runInstall(argv: string[]): number;
package/dist/install.js CHANGED
@@ -147,7 +147,7 @@ export function detectHosts() {
147
147
  export function projectOnlyHosts() {
148
148
  return HOST_IDS.filter((id) => isInstalled(id) && !spec(id).userDirName);
149
149
  }
150
- function packageVersion() {
150
+ export function packageVersion() {
151
151
  try {
152
152
  const raw = readFileSync(path.join(PKG_ROOT, 'package.json'), 'utf-8');
153
153
  return JSON.parse(raw).version ?? 'unknown';
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "schemaVersion": 1,
3
- "version": "0.9.35",
4
- "generatedAt": "2026-09-18T05:50:52.540Z",
3
+ "version": "0.9.36",
4
+ "generatedAt": "2026-09-20T05:40:23.517Z",
5
5
  "skills": [
6
6
  {
7
7
  "id": "export-jianying",
@@ -23,6 +23,10 @@ export interface EnsuredTake {
23
23
  projectId: string;
24
24
  created: boolean;
25
25
  attempt: number;
26
+ /** The grouping key this take was resolved under — reused as the trace's session id. */
27
+ sessionKey: string;
28
+ /** Provenance label of the host we're running inside ('' when unknown). */
29
+ host: string;
26
30
  }
27
31
  /**
28
32
  * Look up the take for the current grouping key without creating anything.
@@ -62,6 +62,8 @@ export async function findTake(ctx) {
62
62
  projectId: res.projectId ?? '',
63
63
  created: false,
64
64
  attempt: res.attempt ?? 0,
65
+ sessionKey: takeKey.key,
66
+ host: takeKey.host,
65
67
  };
66
68
  }
67
69
  /**
@@ -98,5 +100,7 @@ export async function ensureTake(ctx, opts) {
98
100
  projectId,
99
101
  created: res.created,
100
102
  attempt: res.attempt,
103
+ sessionKey: takeKey.key,
104
+ host: takeKey.host,
101
105
  };
102
106
  }
@@ -0,0 +1,98 @@
1
+ /**
2
+ * Execution-trace self-reporting — tell the backend what this invocation
3
+ * actually did, so a locally-run pipeline is not a blind spot.
4
+ *
5
+ * Why this exists: the cloud agent records every tool call, its arguments AND
6
+ * its result into `media_chat_turn_trace` (ab-agent/src/trace-persist.ts). The
7
+ * local path records nothing — a take created from codex / claude code has an
8
+ * empty conversation, zero tool calls and zero credits in the analytics tables,
9
+ * so every session-level metric silently excludes it.
10
+ *
11
+ * What we can honestly report and what we cannot: the CLI is a child process
12
+ * spawned once per skill call. It never sees the user's prompt or the host's
13
+ * reply — those live in the host's own transcript. So this module reports the
14
+ * one thing it genuinely observes: the invocation itself.
15
+ *
16
+ * See docs/cli-session-record-design.md §5.
17
+ */
18
+ import { type HttpContext } from '../http.js';
19
+ import type { ParsedArgs } from '../argv.js';
20
+ import type { EnsuredTake } from './take.js';
21
+ export interface TraceOptions {
22
+ skillName: string;
23
+ args: ParsedArgs;
24
+ take: EnsuredTake;
25
+ ctx: HttpContext;
26
+ }
27
+ /**
28
+ * Whether this invocation should report at all.
29
+ *
30
+ * The rule is deliberately narrow: report only when THIS process resolved the
31
+ * take. Everything else is already covered by someone else —
32
+ * - under the cloud agent, CONVERSATION_ID arrives pre-set and ab-agent's own
33
+ * TurnTracer already recorded the very same call;
34
+ * - a nested `gen-image` spawned by prepare-video-assets inherits its
35
+ * parent's CONVERSATION_ID, and the parent's row already covers it.
36
+ * Since runner.ts only builds a tracer after resolving a take itself, this
37
+ * function just honours the kill switch.
38
+ */
39
+ export declare function tracingEnabled(env?: NodeJS.ProcessEnv): boolean;
40
+ /**
41
+ * Collects one invocation's evidence and posts it as a single turn.
42
+ *
43
+ * Lifecycle (runner.ts): `begin()` → output flows through the tap → `finish()`
44
+ * in a finally block. Every failure mode ends in a warning on stderr: a lost
45
+ * trace row must never cost the user a render they already paid for.
46
+ */
47
+ export declare class InvocationTrace {
48
+ private readonly opts;
49
+ private chunks;
50
+ private restore;
51
+ private flushed;
52
+ private constructor();
53
+ /** Returns null when tracing is off — callers can then skip output capture entirely. */
54
+ static begin(opts: TraceOptions): InvocationTrace | null;
55
+ /**
56
+ * Tap both output streams.
57
+ *
58
+ * One tap covers both dispatch paths: in-process handlers write here
59
+ * directly, and the Python child's piped output is re-emitted through the
60
+ * same `write` (see runner.ts). Restored in finish().
61
+ */
62
+ installCapture(): void;
63
+ /** Feed captured output. Keeps a bounded tail — the decisive line is the last one. */
64
+ absorb(chunk: unknown): void;
65
+ /**
66
+ * Emit the turn. Idempotent; never throws.
67
+ *
68
+ * `error` is the dispatcher's caught exception when there is one — a Python
69
+ * child that exits non-zero gives us no exception, only its output, which is
70
+ * exactly why the tail buffer exists.
71
+ */
72
+ finish(exitCode: number, durationMs: number, error?: unknown): Promise<void>;
73
+ }
74
+ /**
75
+ * Flags → a trace-safe object.
76
+ *
77
+ * Credentials are dropped outright; long strings are cut to their opening
78
+ * because an argument like `--prompt` is *content*, and this table is evidence,
79
+ * not an archive of what the user wrote.
80
+ */
81
+ export declare function sanitiseArgs(args: ParsedArgs): Record<string, unknown>;
82
+ /**
83
+ * Captured output → the tail we store.
84
+ *
85
+ * Two transformations before truncating, both about spending the 16KB on
86
+ * things a human would read:
87
+ * - progress lines are the machine protocol — one fat JSON object per phase,
88
+ * marked by a `__progress__` field (progress.ts, and the same shape from the
89
+ * Python skills). Their payload is noise here, but WHICH PHASE a failed run
90
+ * reached is the single most useful fact about it, so the line collapses to
91
+ * its phase name instead of being dropped.
92
+ * - `__diagnostic_v1__` lines are our own structured log of this very run;
93
+ * keeping them would store the same verdict twice.
94
+ *
95
+ * Then keeps the TAIL, unlike ab-agent's head-first cleanText: a command's
96
+ * verdict — the error, or the URL it produced — is its last line.
97
+ */
98
+ export declare function condense(raw: string): string;
@@ -0,0 +1,278 @@
1
+ /**
2
+ * Execution-trace self-reporting — tell the backend what this invocation
3
+ * actually did, so a locally-run pipeline is not a blind spot.
4
+ *
5
+ * Why this exists: the cloud agent records every tool call, its arguments AND
6
+ * its result into `media_chat_turn_trace` (ab-agent/src/trace-persist.ts). The
7
+ * local path records nothing — a take created from codex / claude code has an
8
+ * empty conversation, zero tool calls and zero credits in the analytics tables,
9
+ * so every session-level metric silently excludes it.
10
+ *
11
+ * What we can honestly report and what we cannot: the CLI is a child process
12
+ * spawned once per skill call. It never sees the user's prompt or the host's
13
+ * reply — those live in the host's own transcript. So this module reports the
14
+ * one thing it genuinely observes: the invocation itself.
15
+ *
16
+ * See docs/cli-session-record-design.md §5.
17
+ */
18
+ import os from 'node:os';
19
+ import { cleanDiagnosticText, outcomeOf } from '../diagnostics.js';
20
+ import { billingSummary } from '../billing.js';
21
+ import { packageVersion } from '../install.js';
22
+ import { mmPost } from '../http.js';
23
+ /** Same ceiling ab-agent uses for a tool result — one table, one limit. */
24
+ const TOOL_RESULT_MAX = 16 * 1024;
25
+ /** Raw bytes held before trimming. Generous: trimming is the tail, cleaning is cheap. */
26
+ const RAW_CAPTURE_MAX = 64 * 1024;
27
+ /** Per-argument ceiling. Prompts and narration bodies are content, not evidence. */
28
+ const ARG_VALUE_MAX = 512;
29
+ /** `error_summary` is varchar(1024). */
30
+ const ERROR_SUMMARY_MAX = 1024;
31
+ const FLUSH_TIMEOUT_MS = 10_000;
32
+ const SENSITIVE_KEY = /token|secret|password|api[_-]?key|authorization/i;
33
+ /**
34
+ * Whether this invocation should report at all.
35
+ *
36
+ * The rule is deliberately narrow: report only when THIS process resolved the
37
+ * take. Everything else is already covered by someone else —
38
+ * - under the cloud agent, CONVERSATION_ID arrives pre-set and ab-agent's own
39
+ * TurnTracer already recorded the very same call;
40
+ * - a nested `gen-image` spawned by prepare-video-assets inherits its
41
+ * parent's CONVERSATION_ID, and the parent's row already covers it.
42
+ * Since runner.ts only builds a tracer after resolving a take itself, this
43
+ * function just honours the kill switch.
44
+ */
45
+ export function tracingEnabled(env = process.env) {
46
+ return (env.REMIXMATE_TRACE ?? '').trim() !== '0';
47
+ }
48
+ /**
49
+ * Collects one invocation's evidence and posts it as a single turn.
50
+ *
51
+ * Lifecycle (runner.ts): `begin()` → output flows through the tap → `finish()`
52
+ * in a finally block. Every failure mode ends in a warning on stderr: a lost
53
+ * trace row must never cost the user a render they already paid for.
54
+ */
55
+ export class InvocationTrace {
56
+ opts;
57
+ chunks = '';
58
+ restore = null;
59
+ flushed = false;
60
+ constructor(opts) {
61
+ this.opts = opts;
62
+ }
63
+ /** Returns null when tracing is off — callers can then skip output capture entirely. */
64
+ static begin(opts) {
65
+ if (!tracingEnabled())
66
+ return null;
67
+ if (!opts.take.conversationId)
68
+ return null;
69
+ return new InvocationTrace(opts);
70
+ }
71
+ /**
72
+ * Tap both output streams.
73
+ *
74
+ * One tap covers both dispatch paths: in-process handlers write here
75
+ * directly, and the Python child's piped output is re-emitted through the
76
+ * same `write` (see runner.ts). Restored in finish().
77
+ */
78
+ installCapture() {
79
+ if (this.restore)
80
+ return;
81
+ const out = process.stdout.write.bind(process.stdout);
82
+ const err = process.stderr.write.bind(process.stderr);
83
+ const tap = (original) => ((chunk, ...rest) => {
84
+ this.absorb(chunk);
85
+ return original(chunk, ...rest);
86
+ });
87
+ process.stdout.write = tap(out);
88
+ process.stderr.write = tap(err);
89
+ this.restore = () => {
90
+ process.stdout.write = out;
91
+ process.stderr.write = err;
92
+ };
93
+ }
94
+ /** Feed captured output. Keeps a bounded tail — the decisive line is the last one. */
95
+ absorb(chunk) {
96
+ try {
97
+ const text = typeof chunk === 'string'
98
+ ? chunk
99
+ : chunk instanceof Uint8Array
100
+ ? Buffer.from(chunk).toString('utf8')
101
+ : '';
102
+ if (!text)
103
+ return;
104
+ this.chunks += text;
105
+ if (this.chunks.length > RAW_CAPTURE_MAX * 2) {
106
+ this.chunks = this.chunks.slice(-RAW_CAPTURE_MAX);
107
+ }
108
+ }
109
+ catch {
110
+ // Capturing must never break the thing being captured.
111
+ }
112
+ }
113
+ /**
114
+ * Emit the turn. Idempotent; never throws.
115
+ *
116
+ * `error` is the dispatcher's caught exception when there is one — a Python
117
+ * child that exits non-zero gives us no exception, only its output, which is
118
+ * exactly why the tail buffer exists.
119
+ */
120
+ async finish(exitCode, durationMs, error) {
121
+ this.restore?.();
122
+ this.restore = null;
123
+ if (this.flushed)
124
+ return;
125
+ this.flushed = true;
126
+ const { skillName, args, take, ctx } = this.opts;
127
+ const output = condense(this.chunks);
128
+ const outcome = outcomeOf(exitCode);
129
+ const isError = exitCode !== 0;
130
+ const rows = [];
131
+ let stepSeq = 0;
132
+ // One env row per take, not per call: it describes the toolchain, and the
133
+ // toolchain does not change between two calls of the same pipeline.
134
+ if (take.created) {
135
+ rows.push({ sessionId: take.sessionKey, stepSeq: stepSeq++, kind: 'env', env: envSnapshot(take) });
136
+ }
137
+ rows.push({
138
+ sessionId: take.sessionKey,
139
+ stepSeq: stepSeq++,
140
+ kind: 'tool_call',
141
+ toolName: `cli__${skillName.replace(/-/g, '_')}`,
142
+ toolUseId: (process.env.AB_OPERATION_ID ?? '').slice(0, 64),
143
+ toolInput: sanitiseArgs(args),
144
+ toolResult: output || '[no output captured]',
145
+ isError,
146
+ durationMs: Math.round(durationMs),
147
+ });
148
+ rows.push({
149
+ sessionId: take.sessionKey,
150
+ stepSeq: stepSeq++,
151
+ kind: 'turn_result',
152
+ outcome,
153
+ errorSummary: isError ? summariseError(error, output) : '',
154
+ credits: billingSummary()?.credits ?? 0,
155
+ });
156
+ try {
157
+ await mmPost(ctx, `/chat/conversation/${encodeURIComponent(take.conversationId)}/trace`,
158
+ // appendTurn: the server allocates turn_seq. N unrelated processes
159
+ // cannot number themselves, and cli-project-binding §5.1 already ruled
160
+ // out keeping local state. Older backends ignore the field and every
161
+ // row lands on turn 0 — still ordered, just ungrouped.
162
+ { appendTurn: true, traces: rows }, { timeoutMs: FLUSH_TIMEOUT_MS });
163
+ }
164
+ catch (err) {
165
+ process.stderr.write(`⚠️ 执行轨迹未能上报(不影响本次产物): ${cleanDiagnosticText(err.message, 256)}\n`);
166
+ }
167
+ }
168
+ }
169
+ /** Which rung of the grouping ladder produced this key (cli-project-binding §5.1). */
170
+ function keySource(key) {
171
+ if (key.startsWith('custom:'))
172
+ return 'env-override';
173
+ if (key.startsWith('job:'))
174
+ return 'render-job';
175
+ return 'host';
176
+ }
177
+ /**
178
+ * What this take was produced by. Mirrors ab-agent's envSnapshot() in purpose:
179
+ * answer "which version started failing" without storing anything large.
180
+ */
181
+ function envSnapshot(take) {
182
+ return {
183
+ // The discriminator every cross-channel metric needs — a CLI "turn" is one
184
+ // skill call, a cloud turn is a whole user request. Never compare blindly.
185
+ channel: 'cli',
186
+ cli_version: packageVersion(),
187
+ host: take.host || 'unknown',
188
+ take_key_source: keySource(take.sessionKey),
189
+ node: process.version,
190
+ platform: `${os.platform()}-${os.arch()}`,
191
+ };
192
+ }
193
+ /**
194
+ * Flags → a trace-safe object.
195
+ *
196
+ * Credentials are dropped outright; long strings are cut to their opening
197
+ * because an argument like `--prompt` is *content*, and this table is evidence,
198
+ * not an archive of what the user wrote.
199
+ */
200
+ export function sanitiseArgs(args) {
201
+ const out = {};
202
+ for (const [key, value] of Object.entries(args).slice(0, 40)) {
203
+ if (SENSITIVE_KEY.test(key)) {
204
+ out[key] = '[redacted]';
205
+ continue;
206
+ }
207
+ if (typeof value === 'boolean') {
208
+ out[key] = value;
209
+ continue;
210
+ }
211
+ if (Array.isArray(value)) {
212
+ out[key] = value.slice(0, 20).map((v) => cleanDiagnosticText(String(v), ARG_VALUE_MAX));
213
+ continue;
214
+ }
215
+ out[key] = cleanDiagnosticText(String(value), ARG_VALUE_MAX);
216
+ }
217
+ return out;
218
+ }
219
+ /**
220
+ * Captured output → the tail we store.
221
+ *
222
+ * Two transformations before truncating, both about spending the 16KB on
223
+ * things a human would read:
224
+ * - progress lines are the machine protocol — one fat JSON object per phase,
225
+ * marked by a `__progress__` field (progress.ts, and the same shape from the
226
+ * Python skills). Their payload is noise here, but WHICH PHASE a failed run
227
+ * reached is the single most useful fact about it, so the line collapses to
228
+ * its phase name instead of being dropped.
229
+ * - `__diagnostic_v1__` lines are our own structured log of this very run;
230
+ * keeping them would store the same verdict twice.
231
+ *
232
+ * Then keeps the TAIL, unlike ab-agent's head-first cleanText: a command's
233
+ * verdict — the error, or the URL it produced — is its last line.
234
+ */
235
+ export function condense(raw) {
236
+ if (!raw)
237
+ return '';
238
+ const compacted = raw
239
+ .split('\n')
240
+ .map((line) => {
241
+ const trimmed = line.trim();
242
+ if (trimmed.startsWith('__diagnostic_v1__'))
243
+ return null;
244
+ if (!trimmed.startsWith('{') || !trimmed.includes('"__progress__"'))
245
+ return line;
246
+ try {
247
+ const phase = JSON.parse(trimmed).phase;
248
+ return typeof phase === 'string' ? `· phase=${phase}` : '· progress';
249
+ }
250
+ catch {
251
+ return '· progress';
252
+ }
253
+ })
254
+ .filter((line) => line !== null)
255
+ .join('\n')
256
+ .trim();
257
+ // Slice the tail before cleaning: cleanDiagnosticText truncates head-first.
258
+ const tail = compacted.length > TOOL_RESULT_MAX ? compacted.slice(-TOOL_RESULT_MAX) : compacted;
259
+ return cleanDiagnosticText(tail, TOOL_RESULT_MAX);
260
+ }
261
+ /**
262
+ * The one-line verdict.
263
+ *
264
+ * A thrown SkillError carries it directly. A Python child does not throw — it
265
+ * just exits non-zero, so fall back to the last line it printed, which is where
266
+ * argparse errors, tracebacks and our own ❌ messages end up.
267
+ */
268
+ function summariseError(error, output) {
269
+ if (error instanceof Error && error.message) {
270
+ return cleanDiagnosticText(error.message, ERROR_SUMMARY_MAX);
271
+ }
272
+ const lastLine = output
273
+ .split('\n')
274
+ .map((l) => l.trim())
275
+ .filter((l) => l && !l.startsWith('· phase='))
276
+ .pop();
277
+ return lastLine ? cleanDiagnosticText(lastLine, ERROR_SUMMARY_MAX) : '';
278
+ }
package/dist/runner.js CHANGED
@@ -19,6 +19,7 @@ import { authChildEnv, ensureAuth } from './auth/ensure.js';
19
19
  import { emitBillingFooter } from './billing.js';
20
20
  import { resolveHttpContext } from './http.js';
21
21
  import { ensureTake, findTake } from './project/take.js';
22
+ import { InvocationTrace } from './project/trace.js';
22
23
  /** Read the token override accepted by both the TS handlers and the Python skills. */
23
24
  function tokenFlag(args) {
24
25
  const value = args.token ?? args.priv_token;
@@ -28,6 +29,7 @@ export async function runSkill(skillName, opts) {
28
29
  const started = performance.now();
29
30
  let exitCode = EXIT.ERROR;
30
31
  let failure;
32
+ let trace = null;
31
33
  traceHeaders();
32
34
  const skill = findSkill(skillName, opts.baseDir ?? SKILLS_DIR);
33
35
  if (!skill) {
@@ -41,25 +43,43 @@ export async function runSkill(skillName, opts) {
41
43
  apiBaseUrl: typeof opts.parsedArgs.api_base_url === 'string' ? opts.parsedArgs.api_base_url : undefined,
42
44
  flagToken: tokenFlag(opts.parsedArgs),
43
45
  });
44
- await applyTakeContext(skill, opts, auth);
46
+ const attached = await applyTakeContext(skill, opts, auth);
47
+ if (attached) {
48
+ // Only a take THIS process resolved gets reported — see trace.ts for why
49
+ // the cloud path and nested pipeline children must stay silent.
50
+ trace = InvocationTrace.begin({
51
+ skillName: skill.name,
52
+ args: opts.parsedArgs,
53
+ take: attached.take,
54
+ ctx: attached.ctx,
55
+ });
56
+ trace?.installCapture();
57
+ }
45
58
  switch (skill.entry.type) {
46
59
  case 'python':
47
60
  // Python children talk to ab-api themselves and print their own billing
48
61
  // footer; nothing was charged through this process.
49
- return exitCode = await runPython(skill, opts.rawArgs, auth);
62
+ exitCode = await runPython(skill, opts.rawArgs, auth, trace);
63
+ break;
50
64
  case 'http':
51
65
  case 'builtin':
52
- return exitCode = await runHandler(skill, opts.parsedArgs, auth);
66
+ exitCode = await runHandler(skill, opts.parsedArgs, auth);
67
+ break;
53
68
  }
54
69
  }
55
70
  catch (err) {
56
71
  failure = err;
57
72
  if (err instanceof SkillError) {
58
73
  process.stderr.write(cleanDiagnosticText(err.message) + '\n');
59
- return exitCode = err.exitCode;
74
+ exitCode = err.exitCode;
60
75
  }
61
- process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
62
- return EXIT.ERROR;
76
+ else {
77
+ process.stderr.write(`❌ unexpected error: ${cleanDiagnosticText(err.stack ?? err.message)}\n`);
78
+ exitCode = EXIT.ERROR;
79
+ }
80
+ // Single exit point below: the trace flush in `finally` must run before the
81
+ // value leaves, and an `await` there defeats TS's reachability analysis on
82
+ // returns made from inside the try/catch.
63
83
  }
64
84
  finally {
65
85
  // In `finally` because a run can fail *after* a billed step (e.g. the image
@@ -67,7 +87,12 @@ export async function runSkill(skillName, opts) {
67
87
  // reported either way. No-ops when nothing was charged.
68
88
  diagnostic(exitCode, performance.now() - started, failure);
69
89
  emitBillingFooter();
90
+ // Last, so the billing footer is still inside the captured tail. Never
91
+ // throws and never changes the exit code — a lost trace row must not cost
92
+ // the user a render they already paid for.
93
+ await trace?.finish(exitCode, performance.now() - started, failure);
70
94
  }
95
+ return exitCode;
71
96
  }
72
97
  /**
73
98
  * Decide the take mode from the skill's declaration *and* what this particular
@@ -118,11 +143,11 @@ export function resolveTakeMode(skill, args) {
118
143
  async function applyTakeContext(skill, opts, auth) {
119
144
  const mode = resolveTakeMode(skill, opts.parsedArgs);
120
145
  if (mode === 'none')
121
- return;
146
+ return null;
122
147
  if ((process.env.CONVERSATION_ID ?? '').trim())
123
- return;
148
+ return null;
124
149
  if (!auth.token)
125
- return;
150
+ return null;
126
151
  try {
127
152
  const ctx = await resolveHttpContext(skill.name, {
128
153
  apiBaseUrl: auth.apiBaseUrl,
@@ -142,16 +167,32 @@ async function applyTakeContext(skill, opts, auth) {
142
167
  take = await findTake(ctx);
143
168
  }
144
169
  if (!take)
145
- return;
170
+ return null;
146
171
  process.env.CONVERSATION_ID = take.conversationId;
147
172
  process.stdout.write(`📁 take ${take.created ? 'created' : 'reused'}: ${take.videoProjectId} ` +
148
173
  `(project ${take.projectId}${take.attempt ? `, attempt ${take.attempt}` : ''})\n`);
174
+ return { take, ctx };
149
175
  }
150
176
  catch (err) {
151
177
  process.stderr.write(`⚠️ 无法归属本次产物(内容仍会正常生成,但不会出现在项目里): ${cleanDiagnosticText(err.message)}\n`);
178
+ return null;
152
179
  }
153
180
  }
154
- async function runPython(skill, rawArgs, auth) {
181
+ /**
182
+ * Spawn the Python skill.
183
+ *
184
+ * `stdio: 'inherit'` is the default and stays the default: the child writes
185
+ * straight to the terminal, which is exactly what a CLI should do. It does mean
186
+ * the parent sees nothing, so a failed render can only ever be reported as
187
+ * "exit code 1" — for the most valuable step in the pipeline.
188
+ *
189
+ * So when (and only when) we are tracing, stdout/stderr are piped and re-emitted
190
+ * verbatim, which routes them through the capture tap. stdin stays inherited so
191
+ * interactive prompts still work. Resolving on `close` rather than `exit` in
192
+ * that mode matters: `exit` can fire while the last chunk is still in the pipe,
193
+ * which is precisely the chunk holding the error message.
194
+ */
195
+ async function runPython(skill, rawArgs, auth, trace) {
155
196
  if (!skill.scriptAbsolutePath) {
156
197
  throw new SkillError(`skill ${skill.name} has entry.type=python but no scriptAbsolutePath`);
157
198
  }
@@ -159,10 +200,14 @@ async function runPython(skill, rawArgs, auth) {
159
200
  return await new Promise((resolve) => {
160
201
  const proc = spawn('python3', [skill.scriptAbsolutePath, ...rawArgs], {
161
202
  cwd,
162
- stdio: 'inherit',
203
+ stdio: trace ? ['inherit', 'pipe', 'pipe'] : 'inherit',
163
204
  env: { ...process.env, ...authChildEnv(auth), PYTHONUNBUFFERED: '1' },
164
205
  });
165
- proc.on('exit', (code) => resolve(code ?? EXIT.ERROR));
206
+ if (trace) {
207
+ proc.stdout?.on('data', (chunk) => process.stdout.write(chunk));
208
+ proc.stderr?.on('data', (chunk) => process.stderr.write(chunk));
209
+ }
210
+ proc.on(trace ? 'close' : 'exit', (code) => resolve(code ?? EXIT.ERROR));
166
211
  proc.on('error', (err) => {
167
212
  process.stderr.write(`❌ failed to spawn python3: ${err.message}\n`);
168
213
  resolve(EXIT.SPAWN_FAILED);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@remixmate/cli",
3
- "version": "0.9.35",
3
+ "version": "0.9.36",
4
4
  "description": "AI media generation skills for Claude Code / Codex — 12 skills covering image, video, voice, digital human, web screenshot, web recording, script, template registry, rendering, Jianying export, and video deconstruction.",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",