@playdrop/playdrop-cli 0.10.0 → 0.10.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. package/config/client-meta.json +2 -1
  2. package/dist/apiClient.d.ts +8 -0
  3. package/dist/apiClient.js +29 -1
  4. package/dist/captureRuntime.d.ts +13 -0
  5. package/dist/captureRuntime.js +21 -0
  6. package/dist/commandContext.js +21 -3
  7. package/dist/commands/captureRemote.d.ts +2 -0
  8. package/dist/commands/captureRemote.js +90 -0
  9. package/dist/commands/review.d.ts +46 -0
  10. package/dist/commands/review.js +353 -0
  11. package/dist/commands/worker/runtime.d.ts +12 -0
  12. package/dist/commands/worker/runtime.js +79 -35
  13. package/dist/commands/worker.d.ts +17 -3
  14. package/dist/commands/worker.js +431 -24
  15. package/dist/index.js +45 -0
  16. package/dist/workspaceAuth.d.ts +2 -0
  17. package/dist/workspaceAuth.js +6 -0
  18. package/node_modules/@playdrop/api-client/dist/client.d.ts +6 -2
  19. package/node_modules/@playdrop/api-client/dist/client.d.ts.map +1 -1
  20. package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts +3 -2
  21. package/node_modules/@playdrop/api-client/dist/domains/admin.d.ts.map +1 -1
  22. package/node_modules/@playdrop/api-client/dist/domains/admin.js +6 -3
  23. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts +4 -1
  24. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.d.ts.map +1 -1
  25. package/node_modules/@playdrop/api-client/dist/domains/agent-tasks.js +36 -0
  26. package/node_modules/@playdrop/api-client/dist/index.d.ts +6 -2
  27. package/node_modules/@playdrop/api-client/dist/index.d.ts.map +1 -1
  28. package/node_modules/@playdrop/api-client/dist/index.js +21 -6
  29. package/node_modules/@playdrop/config/client-meta.json +2 -1
  30. package/node_modules/@playdrop/types/dist/api.d.ts +163 -3
  31. package/node_modules/@playdrop/types/dist/api.d.ts.map +1 -1
  32. package/node_modules/@playdrop/types/dist/api.js +11 -1
  33. package/package.json +1 -1
@@ -0,0 +1,353 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.REQUIRED_REVIEW_EVIDENCE_FILES = exports.REVIEW_CRITERIA = void 0;
7
+ exports.validateGameReviewResult = validateGameReviewResult;
8
+ exports.validateReviewResultCommand = validateReviewResultCommand;
9
+ exports.composeReviewEvidence = composeReviewEvidence;
10
+ exports.createReviewRatingCard = createReviewRatingCard;
11
+ const node_path_1 = __importDefault(require("node:path"));
12
+ const node_fs_1 = require("node:fs");
13
+ const sharp_1 = __importDefault(require("sharp"));
14
+ const output_1 = require("../output");
15
+ exports.REVIEW_CRITERIA = [
16
+ 'Gameplay / Core Loop',
17
+ 'Depth / Replayability',
18
+ 'Controls / Input',
19
+ 'UX / Usability',
20
+ 'First Time User Experience',
21
+ 'Visuals / Art Direction',
22
+ 'Audio / Feedback',
23
+ 'Store Listing & Metadata Accuracy',
24
+ 'Safety / Age Rating / Compliance',
25
+ 'Performance / Stability',
26
+ ];
27
+ const TERMINAL_REVIEW_STATES = new Set([
28
+ 'FAILED',
29
+ 'LOW_QUALITY',
30
+ 'PASSED',
31
+ 'GOOD',
32
+ 'EXCELLENT',
33
+ ]);
34
+ const STATE_OUTCOME = {
35
+ EXCELLENT: 'Excellent',
36
+ FAILED: 'Blocked',
37
+ GOOD: 'Good',
38
+ LOW_QUALITY: 'Limited',
39
+ PASSED: 'Passed',
40
+ };
41
+ exports.REQUIRED_REVIEW_EVIDENCE_FILES = [
42
+ 'core.png',
43
+ 'win.png',
44
+ 'loss.png',
45
+ 'composite.png',
46
+ 'rating-card.png',
47
+ ];
48
+ const REVIEW_EVIDENCE_MAX_BYTES = 10 * 1024 * 1024;
49
+ function resolveFilePath(filePath, code) {
50
+ const normalized = typeof filePath === 'string' ? filePath.trim() : '';
51
+ if (!normalized) {
52
+ throw new Error(code);
53
+ }
54
+ return node_path_1.default.isAbsolute(normalized) ? normalized : node_path_1.default.resolve(process.cwd(), normalized);
55
+ }
56
+ async function readRequiredTextFile(filePath, code) {
57
+ const resolved = resolveFilePath(filePath, code);
58
+ const content = await node_fs_1.promises.readFile(resolved, 'utf8');
59
+ if (!content.trim()) {
60
+ throw new Error(code);
61
+ }
62
+ return content;
63
+ }
64
+ function requireLine(text, prefix) {
65
+ const line = text.split(/\r?\n/).find((entry) => entry.startsWith(prefix));
66
+ if (!line) {
67
+ throw new Error(`missing_review_line:${prefix}`);
68
+ }
69
+ return line.slice(prefix.length).trim();
70
+ }
71
+ function normalizeReviewText(...values) {
72
+ return values
73
+ .join(' ')
74
+ .toLowerCase()
75
+ .replace(/\s+/g, ' ')
76
+ .trim();
77
+ }
78
+ function parseScores(reviewMessage) {
79
+ if (/\/5\b/.test(reviewMessage)) {
80
+ throw new Error('review_scores_must_use_10_point_scale');
81
+ }
82
+ const scores = {};
83
+ for (const criterion of exports.REVIEW_CRITERIA) {
84
+ const escaped = criterion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
85
+ const regex = new RegExp(`^- ${escaped}: (10|[1-9])/10 \\| .+`, 'm');
86
+ const match = reviewMessage.match(regex);
87
+ if (!match) {
88
+ throw new Error(`missing_or_invalid_criterion_score:${criterion}`);
89
+ }
90
+ scores[criterion] = Number.parseInt(match[1], 10);
91
+ }
92
+ return scores;
93
+ }
94
+ function averageScore(scores) {
95
+ const values = Object.values(scores);
96
+ return values.reduce((sum, value) => sum + value, 0) / values.length;
97
+ }
98
+ function minimumScore(scores) {
99
+ return Math.min(...Object.values(scores));
100
+ }
101
+ function validateOutcomeAgainstScores(outcome, scores) {
102
+ const average = averageScore(scores);
103
+ const minimum = minimumScore(scores);
104
+ const gameplay = scores['Gameplay / Core Loop'];
105
+ const controls = scores['Controls / Input'];
106
+ const ux = scores['UX / Usability'];
107
+ const ftue = scores['First Time User Experience'];
108
+ const visuals = scores['Visuals / Art Direction'];
109
+ const performance = scores['Performance / Stability'];
110
+ const actual = `actual=average=${average.toFixed(2)},minimum=${minimum},gameplay=${gameplay},controls=${controls},ux=${ux},ftue=${ftue},visuals=${visuals},performance=${performance}`;
111
+ if (outcome === 'Excellent' && (average < 8.5 || minimum < 7 || gameplay < 8 || controls < 8 || ux < 8 || ftue < 7 || visuals < 8 || performance < 8)) {
112
+ throw new Error(`excellent_outcome_not_supported_by_scores:requires=average>=8.5,minimum>=7,gameplay>=8,controls>=8,ux>=8,ftue>=7,visuals>=8,performance>=8:${actual}:use_good_or_lower_outcome_unless_scores_are_supported_by_evidence`);
113
+ }
114
+ if (outcome === 'Good' && (average < 7.5 || minimum < 6 || gameplay < 7 || controls < 7 || ux < 7 || ftue < 6 || visuals < 7 || performance < 7)) {
115
+ throw new Error(`good_outcome_not_supported_by_scores:requires=average>=7.5,minimum>=6,gameplay>=7,controls>=7,ux>=7,ftue>=6,visuals>=7,performance>=7:${actual}:use_passed_or_lower_outcome_unless_scores_are_supported_by_evidence`);
116
+ }
117
+ if (outcome === 'Passed' && (average < 6 || minimum < 4 || gameplay < 5 || controls < 5 || performance < 5)) {
118
+ throw new Error(`passed_outcome_not_supported_by_scores:requires=average>=6,minimum>=4,gameplay>=5,controls>=5,performance>=5:${actual}:use_low_quality_or_failed_unless_scores_are_supported_by_evidence`);
119
+ }
120
+ }
121
+ function validateRequiredEvidenceLines(reviewMessage) {
122
+ const primaryVerb = requireLine(reviewMessage, 'Primary player verb: ');
123
+ const primaryEvidence = requireLine(reviewMessage, 'Primary interaction evidence: ');
124
+ const challengeEvidence = requireLine(reviewMessage, 'Challenge evidence: ');
125
+ const punchline = requireLine(reviewMessage, 'Punchline assessment: ');
126
+ const benchmark = requireLine(reviewMessage, 'Comparable benchmark: ');
127
+ if (primaryVerb.length < 3) {
128
+ throw new Error('primary_player_verb_too_vague');
129
+ }
130
+ if (primaryEvidence.length < 20) {
131
+ throw new Error('primary_interaction_evidence_too_vague');
132
+ }
133
+ if (challengeEvidence.length < 20) {
134
+ throw new Error('challenge_evidence_too_vague');
135
+ }
136
+ if (punchline.length < 8 || punchline.length > 160) {
137
+ throw new Error('punchline_assessment_invalid_length');
138
+ }
139
+ if (benchmark.length < 8) {
140
+ throw new Error('comparable_benchmark_too_vague');
141
+ }
142
+ }
143
+ function isNoScoreCaps(scoreCapsApplied) {
144
+ return scoreCapsApplied.trim().toLowerCase() === 'none';
145
+ }
146
+ function validateCapConsistency(input) {
147
+ const normalizedText = normalizeReviewText(input.reviewMessage, input.creatorFeedback);
148
+ const describesZeroChallenge = /\bzero challenge\b|\bno (?:real |meaningful )?challenge\b(?!-)|\bchallenge (?:is )?(?:absent|missing|nonexistent)\b/.test(normalizedText);
149
+ const describesPrimaryMissing = /\bprimary (?:interaction|verb) (?:is )?(?:absent|missing|unavailable)\b/.test(normalizedText);
150
+ if (isNoScoreCaps(input.scoreCapsApplied) && (describesZeroChallenge || describesPrimaryMissing)) {
151
+ const trigger = describesZeroChallenge ? 'zero_or_missing_challenge' : 'primary_interaction_missing';
152
+ throw new Error(`missing_required_score_cap:${trigger}:score_caps_applied_must_name_the_triggered_cap_or_adjust_the_evidence_wording`);
153
+ }
154
+ if (describesZeroChallenge && (['Passed', 'Good', 'Excellent'].includes(input.outcome) || input.scores['Gameplay / Core Loop'] > 3 || input.scores['Depth / Replayability'] > 3)) {
155
+ throw new Error('gameplay_challenge_absent_score_cap_required');
156
+ }
157
+ }
158
+ async function assertPngFile(filePath) {
159
+ const bytes = await node_fs_1.promises.readFile(filePath);
160
+ if (bytes.length <= 0 || bytes.length > REVIEW_EVIDENCE_MAX_BYTES) {
161
+ throw new Error(`invalid_review_evidence_size:${filePath}`);
162
+ }
163
+ const signature = [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a];
164
+ for (let index = 0; index < signature.length; index += 1) {
165
+ if (bytes[index] !== signature[index]) {
166
+ throw new Error(`review_evidence_not_png:${filePath}`);
167
+ }
168
+ }
169
+ }
170
+ async function validateGameReviewResult(input) {
171
+ const normalizedState = String(input.reviewState || '').trim().toUpperCase();
172
+ if (!TERMINAL_REVIEW_STATES.has(normalizedState)) {
173
+ return { skipped: true, reason: 'non_terminal_state' };
174
+ }
175
+ if (!input.reviewMessage.trim()) {
176
+ throw new Error('missing_review_message');
177
+ }
178
+ const creatorFeedback = input.creatorFeedback ?? '';
179
+ if (!creatorFeedback.trim()) {
180
+ throw new Error('missing_creator_feedback');
181
+ }
182
+ if (!input.evidenceDir.trim()) {
183
+ throw new Error('missing_evidence_dir');
184
+ }
185
+ const expectedOutcome = STATE_OUTCOME[normalizedState];
186
+ const actualOutcome = requireLine(input.reviewMessage, 'Outcome: ');
187
+ if (actualOutcome !== expectedOutcome) {
188
+ throw new Error(`review_outcome_state_mismatch:${actualOutcome}:${normalizedState}`);
189
+ }
190
+ const primarySurface = requireLine(input.reviewMessage, 'Primary reviewed surface: ');
191
+ if (!['MOBILE_PORTRAIT', 'MOBILE_LANDSCAPE', 'DESKTOP'].includes(primarySurface)) {
192
+ throw new Error(`invalid_primary_reviewed_surface:${primarySurface}`);
193
+ }
194
+ validateRequiredEvidenceLines(input.reviewMessage);
195
+ const scoreCapsApplied = requireLine(input.reviewMessage, 'Score caps applied: ');
196
+ if (!creatorFeedback.includes(`Reviewed surface: ${primarySurface}`)) {
197
+ throw new Error('creator_feedback_missing_reviewed_surface');
198
+ }
199
+ const scores = parseScores(input.reviewMessage);
200
+ validateOutcomeAgainstScores(actualOutcome, scores);
201
+ validateCapConsistency({
202
+ creatorFeedback,
203
+ outcome: actualOutcome,
204
+ reviewMessage: input.reviewMessage,
205
+ scoreCapsApplied,
206
+ scores,
207
+ });
208
+ const evidenceDir = resolveFilePath(input.evidenceDir, 'missing_evidence_dir');
209
+ for (const filename of exports.REQUIRED_REVIEW_EVIDENCE_FILES) {
210
+ await assertPngFile(node_path_1.default.join(evidenceDir, filename));
211
+ }
212
+ return {
213
+ average: averageScore(scores),
214
+ evidenceDir,
215
+ outcome: actualOutcome,
216
+ primarySurface,
217
+ scoreCount: Object.keys(scores).length,
218
+ skipped: false,
219
+ };
220
+ }
221
+ async function validateReviewResultCommand(options) {
222
+ const result = await validateGameReviewResult({
223
+ creatorFeedback: options.creatorFeedbackFile ? await readRequiredTextFile(options.creatorFeedbackFile, 'invalid_creator_feedback_file') : '',
224
+ evidenceDir: options.evidenceDir,
225
+ reviewMessage: await readRequiredTextFile(options.messageFile, 'invalid_review_message_file'),
226
+ reviewState: options.state,
227
+ });
228
+ console.log(JSON.stringify(result, null, 2));
229
+ }
230
+ async function resizeForComposite(filePath) {
231
+ const image = (0, sharp_1.default)(resolveFilePath(filePath, 'invalid_review_evidence_file'));
232
+ const metadata = await image.metadata();
233
+ if (!metadata.width || !metadata.height) {
234
+ throw new Error('invalid_review_evidence_image');
235
+ }
236
+ const buffer = await image
237
+ .resize({ width: 540, height: 960, fit: 'inside', withoutEnlargement: true })
238
+ .png()
239
+ .toBuffer();
240
+ const resizedMetadata = await (0, sharp_1.default)(buffer).metadata();
241
+ if (!resizedMetadata.width || !resizedMetadata.height) {
242
+ throw new Error('invalid_review_evidence_image');
243
+ }
244
+ return { buffer, width: resizedMetadata.width, height: resizedMetadata.height };
245
+ }
246
+ async function composeReviewEvidence(options) {
247
+ const entries = [
248
+ options.core ? { label: 'core', path: options.core } : null,
249
+ options.win ? { label: 'win', path: options.win } : null,
250
+ options.loss ? { label: 'loss', path: options.loss } : null,
251
+ ].filter((entry) => Boolean(entry));
252
+ if (entries.length === 0) {
253
+ throw new Error('missing_review_evidence_images');
254
+ }
255
+ const images = await Promise.all(entries.map((entry) => resizeForComposite(entry.path)));
256
+ const padding = 16;
257
+ const width = images.reduce((sum, image) => sum + image.width, padding * (images.length + 1));
258
+ const height = Math.max(...images.map((image) => image.height)) + padding * 2;
259
+ let left = padding;
260
+ const composites = images.map((image) => {
261
+ const composite = { input: image.buffer, left, top: padding };
262
+ left += image.width + padding;
263
+ return composite;
264
+ });
265
+ const outputPath = resolveFilePath(options.out, 'missing_out');
266
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
267
+ await (0, sharp_1.default)({
268
+ create: {
269
+ width,
270
+ height,
271
+ channels: 4,
272
+ background: { r: 12, g: 12, b: 16, alpha: 1 },
273
+ },
274
+ })
275
+ .composite(composites)
276
+ .png()
277
+ .toFile(outputPath);
278
+ (0, output_1.printSuccess)(`Review evidence composite written to ${outputPath}`);
279
+ return { outputPath, imageCount: images.length };
280
+ }
281
+ function escapeXml(value) {
282
+ return value
283
+ .replace(/&/g, '&amp;')
284
+ .replace(/</g, '&lt;')
285
+ .replace(/>/g, '&gt;')
286
+ .replace(/"/g, '&quot;');
287
+ }
288
+ function parseCriterionRating(reviewMessage, criterion) {
289
+ const escaped = criterion.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
290
+ const regex = new RegExp(`^- ${escaped}: (10|[1-9])/10 \\| .+`, 'm');
291
+ const match = reviewMessage.match(regex);
292
+ if (!match) {
293
+ throw new Error(`missing_or_invalid_criterion_score:${criterion}`);
294
+ }
295
+ return Number.parseInt(match[1], 10);
296
+ }
297
+ function ratingColor(score) {
298
+ if (score <= 1)
299
+ return '#ff3b30';
300
+ if (score <= 4)
301
+ return '#ff9500';
302
+ if (score <= 6)
303
+ return '#ffd60a';
304
+ if (score <= 9)
305
+ return '#34c759';
306
+ return '#bf5af2';
307
+ }
308
+ function outcomeColor(outcome) {
309
+ if (outcome === 'Blocked')
310
+ return '#ff3b30';
311
+ if (outcome === 'Limited')
312
+ return '#ff9500';
313
+ if (outcome === 'Passed')
314
+ return '#ffd60a';
315
+ if (outcome === 'Good')
316
+ return '#34c759';
317
+ if (outcome === 'Excellent')
318
+ return '#bf5af2';
319
+ return '#ff9500';
320
+ }
321
+ async function createReviewRatingCard(options) {
322
+ const reviewMessage = await readRequiredTextFile(options.reviewMessageFile, 'invalid_review_message_file');
323
+ const outcome = requireLine(reviewMessage, 'Outcome: ');
324
+ const punchline = (options.punchline?.trim() || requireLine(reviewMessage, 'Punchline assessment: ')).slice(0, 160);
325
+ const title = (options.title?.trim() || 'Game review ratings').slice(0, 80);
326
+ const rows = exports.REVIEW_CRITERIA.map((criterion, index) => {
327
+ const score = parseCriterionRating(reviewMessage, criterion);
328
+ const x = index < 5 ? 56 : 628;
329
+ const y = 170 + (index % 5) * 92;
330
+ return `
331
+ <rect x="${x}" y="${y}" width="516" height="70" rx="6" fill="#15151c" stroke="#333340"/>
332
+ <rect x="${x}" y="${y}" width="8" height="70" fill="${ratingColor(score)}"/>
333
+ <text x="${x + 24}" y="${y + 42}" fill="#f5f5f7" font-size="22" font-weight="700">${escapeXml(criterion)}</text>
334
+ <text x="${x + 448}" y="${y + 44}" fill="${ratingColor(score)}" font-size="32" font-weight="800" text-anchor="end">${score}/10</text>
335
+ `;
336
+ }).join('');
337
+ const svg = `
338
+ <svg width="1200" height="760" xmlns="http://www.w3.org/2000/svg">
339
+ <rect width="1200" height="760" fill="#000"/>
340
+ <text x="56" y="82" fill="#fff" font-size="38" font-weight="800">${escapeXml(title)}</text>
341
+ <text x="1144" y="82" fill="${outcomeColor(outcome)}" font-size="30" font-weight="800" text-anchor="end">${escapeXml(outcome)}</text>
342
+ <line x1="56" y1="122" x2="1144" y2="122" stroke="#333340"/>
343
+ ${rows}
344
+ <line x1="56" y1="650" x2="1144" y2="650" stroke="#333340"/>
345
+ <text x="600" y="708" fill="${outcomeColor(outcome)}" font-size="32" font-weight="800" text-anchor="middle">${escapeXml(punchline)}</text>
346
+ </svg>
347
+ `;
348
+ const outputPath = resolveFilePath(options.out, 'missing_out');
349
+ await node_fs_1.promises.mkdir(node_path_1.default.dirname(outputPath), { recursive: true });
350
+ await (0, sharp_1.default)(Buffer.from(svg)).png().toFile(outputPath);
351
+ (0, output_1.printSuccess)(`Review rating card written to ${outputPath}`);
352
+ return { outputPath, width: 1200, height: 760 };
353
+ }
@@ -9,14 +9,25 @@ export type LoggedProcessResult = {
9
9
  stderr: string;
10
10
  outputTail: string;
11
11
  timedOut: boolean;
12
+ tokenUsage: AgentTokenUsage;
12
13
  tokensUsed: number | null;
13
14
  };
15
+ export type AgentTokenUsage = {
16
+ inputTokens: number | null;
17
+ outputTokens: number | null;
18
+ cacheCreationInputTokens: number | null;
19
+ cacheReadInputTokens: number | null;
20
+ totalTokens: number | null;
21
+ rawProviderUsage: Record<string, unknown> | null;
22
+ usageParseError: string | null;
23
+ };
14
24
  export type LoggedProcessTranscriptChunk = {
15
25
  stream: 'stdout' | 'stderr';
16
26
  content: string;
17
27
  };
18
28
  export declare function readPositiveEnvInt(name: string, fallback: number): number;
19
29
  export declare function readEnvBoolean(name: string, fallback: boolean): boolean;
30
+ export declare function extractAgentTokenUsage(output: string): AgentTokenUsage;
20
31
  export declare function extractCodexTokensUsed(output: string): number | null;
21
32
  export declare function assertWorkerTokenUsageWithinCap(input: {
22
33
  tokensUsed: number | null;
@@ -62,6 +73,7 @@ export declare function buildClaudeDeniedPermissionRules(denyReadRoots?: string[
62
73
  export declare function buildWorkerChildEnv(input: {
63
74
  binDir: string;
64
75
  taskId: number;
76
+ attempt: number;
65
77
  envName: string;
66
78
  eventDir?: string;
67
79
  devPort?: number;
@@ -6,6 +6,7 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.DEFAULT_TRANSCRIPT_FLUSH_INTERVAL_MS = exports.DEFAULT_CODEX_LOG_TAIL_CHARS = exports.DEFAULT_WORKER_TOKEN_CAP = exports.DEFAULT_CODEX_TIMEOUT_MS = void 0;
7
7
  exports.readPositiveEnvInt = readPositiveEnvInt;
8
8
  exports.readEnvBoolean = readEnvBoolean;
9
+ exports.extractAgentTokenUsage = extractAgentTokenUsage;
9
10
  exports.extractCodexTokensUsed = extractCodexTokensUsed;
10
11
  exports.assertWorkerTokenUsageWithinCap = assertWorkerTokenUsageWithinCap;
11
12
  exports.runLoggedProcess = runLoggedProcess;
@@ -52,7 +53,45 @@ function readEnvBoolean(name, fallback) {
52
53
  }
53
54
  throw new Error(`invalid_${name.toLowerCase()}`);
54
55
  }
55
- function extractCodexTokensUsed(output) {
56
+ function normalizeTokenCount(value) {
57
+ if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
58
+ return null;
59
+ }
60
+ return Math.ceil(value);
61
+ }
62
+ function coerceRecord(value) {
63
+ if (!value || typeof value !== 'object' || Array.isArray(value)) {
64
+ return null;
65
+ }
66
+ return value;
67
+ }
68
+ function readStructuredUsage(parsed) {
69
+ const root = coerceRecord(parsed);
70
+ if (!root) {
71
+ return null;
72
+ }
73
+ const directUsage = coerceRecord(root.usage);
74
+ if (directUsage) {
75
+ return directUsage;
76
+ }
77
+ const message = coerceRecord(root.message);
78
+ const messageUsage = coerceRecord(message?.usage);
79
+ if (messageUsage) {
80
+ return messageUsage;
81
+ }
82
+ const event = coerceRecord(root.event);
83
+ const eventUsage = coerceRecord(event?.usage);
84
+ if (eventUsage) {
85
+ return eventUsage;
86
+ }
87
+ const eventMessage = coerceRecord(event?.message);
88
+ const eventMessageUsage = coerceRecord(eventMessage?.usage);
89
+ if (eventMessageUsage) {
90
+ return eventMessageUsage;
91
+ }
92
+ return null;
93
+ }
94
+ function extractAgentTokenUsage(output) {
56
95
  for (const line of output.split(/\r?\n/).reverse()) {
57
96
  const trimmed = line.trim();
58
97
  if (!trimmed.startsWith('{') || !trimmed.endsWith('}')) {
@@ -60,50 +99,49 @@ function extractCodexTokensUsed(output) {
60
99
  }
61
100
  try {
62
101
  const parsed = JSON.parse(trimmed);
63
- const usage = parsed.usage;
102
+ const usage = readStructuredUsage(parsed);
64
103
  if (!usage) {
65
104
  continue;
66
105
  }
67
- const tokenKeys = [
68
- 'input_tokens',
69
- 'output_tokens',
70
- 'cache_creation_input_tokens',
71
- 'cache_read_input_tokens',
106
+ const inputTokens = normalizeTokenCount(usage.input_tokens);
107
+ const outputTokens = normalizeTokenCount(usage.output_tokens);
108
+ const cacheCreationInputTokens = normalizeTokenCount(usage.cache_creation_input_tokens);
109
+ const cacheReadInputTokens = normalizeTokenCount(usage.cache_read_input_tokens);
110
+ const tokenParts = [
111
+ inputTokens,
112
+ outputTokens,
113
+ cacheCreationInputTokens,
114
+ cacheReadInputTokens,
72
115
  ];
73
- let total = 0;
74
- let sawTokenValue = false;
75
- for (const key of tokenKeys) {
76
- const value = usage[key];
77
- if (typeof value !== 'number' || !Number.isFinite(value) || value < 0) {
78
- continue;
79
- }
80
- total += value;
81
- sawTokenValue = true;
82
- }
116
+ const sawTokenValue = tokenParts.some((value) => value !== null);
83
117
  if (sawTokenValue) {
84
- return Math.ceil(total);
118
+ return {
119
+ inputTokens,
120
+ outputTokens,
121
+ cacheCreationInputTokens,
122
+ cacheReadInputTokens,
123
+ totalTokens: tokenParts.reduce((sum, value) => sum + (value ?? 0), 0),
124
+ rawProviderUsage: usage,
125
+ usageParseError: null,
126
+ };
85
127
  }
86
128
  }
87
129
  catch {
88
- // Fall through to the plain-text patterns below.
89
- }
90
- }
91
- const patterns = [
92
- /total\s+tokens\s*:?\s*([\d,]+)/i,
93
- /tokens\s+used\s*:?\s*([\d,]+)/i,
94
- /"total_tokens"\s*:\s*([\d,]+)/i,
95
- ];
96
- for (const pattern of patterns) {
97
- const match = pattern.exec(output);
98
- if (!match?.[1]) {
99
130
  continue;
100
131
  }
101
- const parsed = Number.parseInt(match[1].replace(/,/g, ''), 10);
102
- if (Number.isInteger(parsed) && parsed >= 0) {
103
- return parsed;
104
- }
105
132
  }
106
- return null;
133
+ return {
134
+ inputTokens: null,
135
+ outputTokens: null,
136
+ cacheCreationInputTokens: null,
137
+ cacheReadInputTokens: null,
138
+ totalTokens: null,
139
+ rawProviderUsage: null,
140
+ usageParseError: 'agent_usage_not_reported',
141
+ };
142
+ }
143
+ function extractCodexTokensUsed(output) {
144
+ return extractAgentTokenUsage(output).totalTokens;
107
145
  }
108
146
  function assertWorkerTokenUsageWithinCap(input) {
109
147
  if (input.tokensUsed === null) {
@@ -228,6 +266,7 @@ async function runLoggedProcess(input) {
228
266
  throw transcriptFlushError;
229
267
  }
230
268
  const outputTail = tailText(combined, input.maxOutputChars);
269
+ const tokenUsage = extractAgentTokenUsage(`${stdout}\n${stderr}\n${outputTail}`);
231
270
  resolve({
232
271
  exitCode: code,
233
272
  signal,
@@ -235,7 +274,8 @@ async function runLoggedProcess(input) {
235
274
  stderr,
236
275
  outputTail,
237
276
  timedOut,
238
- tokensUsed: extractCodexTokensUsed(`${stdout}\n${stderr}\n${outputTail}`),
277
+ tokenUsage,
278
+ tokensUsed: tokenUsage.totalTokens,
239
279
  });
240
280
  })().catch(reject);
241
281
  });
@@ -399,7 +439,11 @@ function buildWorkerChildEnv(input) {
399
439
  }
400
440
  }
401
441
  child.PLAYDROP_WORKER_CONTEXT = '1';
442
+ if (!Number.isInteger(input.attempt) || input.attempt <= 0) {
443
+ throw new Error('invalid_worker_task_attempt');
444
+ }
402
445
  child.PLAYDROP_WORKER_TASK_ID = String(input.taskId);
446
+ child.PLAYDROP_WORKER_TASK_ATTEMPT = String(input.attempt);
403
447
  if (input.devPort !== undefined) {
404
448
  if (!Number.isInteger(input.devPort) || input.devPort <= 0 || input.devPort > 65535) {
405
449
  throw new Error('invalid_worker_task_dev_port');
@@ -1,9 +1,9 @@
1
1
  import type { ApiClient } from '@playdrop/api-client';
2
- import { type AgentExecutionTarget, type AgentRuntime, type AgentTaskResponse, type AgentWorkerCapabilities, type WorkerAgentTaskAssignmentResponse, type WorkerAgentTaskBaseSourceResponse, type WorkerAgentTaskWorkspaceFileResponse, type WorkerClaimAgentTaskResponse } from '@playdrop/types';
2
+ import { type AgentExecutionTarget, type AgentRuntime, type AgentTaskKind, type AgentTaskResponse, type AgentWorkerCapabilities, type WorkerAgentTaskAssignmentResponse, type WorkerAgentTaskBaseSourceResponse, type WorkerAgentTaskWorkspaceFileResponse, type WorkerClaimAgentTaskResponse } from '@playdrop/types';
3
3
  import { type WorkerPlaydropAssetRequirement } from './upload';
4
4
  import { type LoggedProcessResult, type LoggedProcessTranscriptChunk } from './worker/runtime';
5
- export { DEFAULT_CODEX_TIMEOUT_MS, DEFAULT_WORKER_TOKEN_CAP, assertWorkerTokenUsageWithinCap, buildCodexExecArgs, buildClaudeExecArgs, buildClaudePermissionSettings, buildWorkerChildEnv, extractCodexTokensUsed, readEnvBoolean, readCodexSandboxMode, runLoggedProcess, } from './worker/runtime';
6
- export type { LoggedProcessResult, LoggedProcessTranscriptChunk, } from './worker/runtime';
5
+ export { DEFAULT_CODEX_TIMEOUT_MS, DEFAULT_WORKER_TOKEN_CAP, assertWorkerTokenUsageWithinCap, buildCodexExecArgs, buildClaudeExecArgs, buildClaudePermissionSettings, buildWorkerChildEnv, extractAgentTokenUsage, extractCodexTokensUsed, readEnvBoolean, readCodexSandboxMode, runLoggedProcess, } from './worker/runtime';
6
+ export type { AgentTokenUsage, LoggedProcessResult, LoggedProcessTranscriptChunk, } from './worker/runtime';
7
7
  export declare const WORKER_SESSION_EXPIRED_MESSAGE = "worker session expired: run \"playdrop auth login\" and start the worker again";
8
8
  export declare const WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = "worker_context_command_not_allowed: inside a PlayDrop worker task only task progress, task upload/done/fail, project validation/build/dev/capture, read-only catalogue/documentation lookup, and PlayDrop AI generation are permitted.";
9
9
  type WorkerStartOptions = {
@@ -33,6 +33,13 @@ type TaskFailOptions = {
33
33
  env?: string;
34
34
  message?: string;
35
35
  };
36
+ type TaskSubmitReviewOptions = {
37
+ env?: string;
38
+ state?: string;
39
+ messageFile?: string;
40
+ creatorFeedbackFile?: string;
41
+ evidenceDir?: string;
42
+ };
36
43
  export type WorkerHealthAlertInput = {
37
44
  state: 'started' | 'stopped' | 'crashed';
38
45
  env: string;
@@ -89,6 +96,7 @@ export declare function resolvePlaydropPluginRoot(input?: {
89
96
  export declare function stagePlaydropPluginReferences(input: {
90
97
  workspaceDir: string;
91
98
  pluginRoot: string;
99
+ kind: AgentTaskKind;
92
100
  }): Promise<string[]>;
93
101
  export declare function discoverWorkerProjectRoot(workspaceDir: string): string;
94
102
  export declare function readWorkerTaskState(): WorkerTaskState | null;
@@ -141,4 +149,10 @@ export declare function reportTask(options: TaskReportOptions): Promise<void>;
141
149
  export declare function reportCatalogueTask(options: TaskCatalogueReportOptions): Promise<void>;
142
150
  export declare function uploadTask(options?: TaskUploadOptions): Promise<void>;
143
151
  export declare function completeTask(options: TaskCompleteOptions): Promise<void>;
152
+ export declare function readReviewEvidenceFiles(evidenceDir: string | undefined): Array<{
153
+ name: string;
154
+ contentType: string;
155
+ contentBase64: string;
156
+ }>;
157
+ export declare function submitReviewTask(options: TaskSubmitReviewOptions): Promise<void>;
144
158
  export declare function failTask(options: TaskFailOptions): Promise<void>;