@playdrop/playdrop-cli 0.10.17 → 0.10.19

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
3
3
  return (mod && mod.__esModule) ? mod : { "default": mod };
4
4
  };
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
- exports.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = exports.WORKER_SESSION_EXPIRED_MESSAGE = exports.runLoggedProcess = exports.readCodexSandboxMode = exports.readEnvBoolean = exports.extractCodexTokensUsed = exports.extractAgentTokenUsage = exports.buildWorkerChildEnv = exports.buildClaudePermissionSettings = exports.buildClaudeExecArgs = exports.buildCodexExecArgs = exports.assertWorkerTokenUsageWithinCap = exports.DEFAULT_WORKER_TOKEN_CAP = exports.DEFAULT_CODEX_TIMEOUT_MS = void 0;
6
+ exports.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = exports.WORKER_SESSION_EXPIRED_MESSAGE = exports.runLoggedProcess = exports.readCodexRolloutTokenUsageForThread = exports.readCodexSandboxMode = exports.readEnvBoolean = exports.extractCodexTokensUsed = exports.extractCodexThreadId = exports.extractCodexRolloutTokenUsage = exports.extractAgentTokenUsage = exports.buildWorkerChildEnv = exports.buildClaudePermissionSettings = exports.buildClaudeExecArgs = exports.buildCodexExecArgs = exports.assertWorkerTokenUsageWithinCap = exports.DEFAULT_WORKER_TOKEN_CAP = exports.DEFAULT_CODEX_TIMEOUT_MS = void 0;
7
7
  exports.allocateWorkerDevPort = allocateWorkerDevPort;
8
8
  exports.isWorkerContextCommandAllowed = isWorkerContextCommandAllowed;
9
9
  exports.resolveWorkerExecutionTargetFromRole = resolveWorkerExecutionTargetFromRole;
@@ -13,6 +13,7 @@ exports.claimBackoffDelayMs = claimBackoffDelayMs;
13
13
  exports.resolveWorkerHomeDir = resolveWorkerHomeDir;
14
14
  exports.hasAgentTaskUploadedArtifact = hasAgentTaskUploadedArtifact;
15
15
  exports.appendWorkerTranscriptChunks = appendWorkerTranscriptChunks;
16
+ exports.readTaskNextStepsFile = readTaskNextStepsFile;
16
17
  exports.assertTaskUploadResultMatchesContext = assertTaskUploadResultMatchesContext;
17
18
  exports.stageAssignmentWorkspaceFiles = stageAssignmentWorkspaceFiles;
18
19
  exports.resolvePlaydropPluginRoot = resolvePlaydropPluginRoot;
@@ -22,6 +23,8 @@ exports.readWorkerTaskState = readWorkerTaskState;
22
23
  exports.buildWorkerHealthAlertText = buildWorkerHealthAlertText;
23
24
  exports.isWorkerAuthFailureError = isWorkerAuthFailureError;
24
25
  exports.classifyWorkerEventDrainError = classifyWorkerEventDrainError;
26
+ exports.isWorkerTransientNetworkError = isWorkerTransientNetworkError;
27
+ exports.classifyWorkerHeartbeatError = classifyWorkerHeartbeatError;
25
28
  exports.assertWorkerClaimErrorRetryable = assertWorkerClaimErrorRetryable;
26
29
  exports.resolvePlaydropAssetRequirementFromText = resolvePlaydropAssetRequirementFromText;
27
30
  exports.loadWorkerEnvFile = loadWorkerEnvFile;
@@ -44,6 +47,7 @@ exports.startWorker = startWorker;
44
47
  exports.reportTask = reportTask;
45
48
  exports.reportCatalogueTask = reportCatalogueTask;
46
49
  exports.uploadTask = uploadTask;
50
+ exports.claimSlugTask = claimSlugTask;
47
51
  exports.completeTask = completeTask;
48
52
  exports.readReviewEvidenceFiles = readReviewEvidenceFiles;
49
53
  exports.submitReviewTask = submitReviewTask;
@@ -75,12 +79,16 @@ Object.defineProperty(exports, "buildClaudeExecArgs", { enumerable: true, get: f
75
79
  Object.defineProperty(exports, "buildClaudePermissionSettings", { enumerable: true, get: function () { return runtime_2.buildClaudePermissionSettings; } });
76
80
  Object.defineProperty(exports, "buildWorkerChildEnv", { enumerable: true, get: function () { return runtime_2.buildWorkerChildEnv; } });
77
81
  Object.defineProperty(exports, "extractAgentTokenUsage", { enumerable: true, get: function () { return runtime_2.extractAgentTokenUsage; } });
82
+ Object.defineProperty(exports, "extractCodexRolloutTokenUsage", { enumerable: true, get: function () { return runtime_2.extractCodexRolloutTokenUsage; } });
83
+ Object.defineProperty(exports, "extractCodexThreadId", { enumerable: true, get: function () { return runtime_2.extractCodexThreadId; } });
78
84
  Object.defineProperty(exports, "extractCodexTokensUsed", { enumerable: true, get: function () { return runtime_2.extractCodexTokensUsed; } });
79
85
  Object.defineProperty(exports, "readEnvBoolean", { enumerable: true, get: function () { return runtime_2.readEnvBoolean; } });
80
86
  Object.defineProperty(exports, "readCodexSandboxMode", { enumerable: true, get: function () { return runtime_2.readCodexSandboxMode; } });
87
+ Object.defineProperty(exports, "readCodexRolloutTokenUsageForThread", { enumerable: true, get: function () { return runtime_2.readCodexRolloutTokenUsageForThread; } });
81
88
  Object.defineProperty(exports, "runLoggedProcess", { enumerable: true, get: function () { return runtime_2.runLoggedProcess; } });
82
89
  const DEFAULT_POLL_INTERVAL_MS = 1000;
83
90
  const HEARTBEAT_INTERVAL_MS = 10000;
91
+ const TASK_HEARTBEAT_TRANSIENT_FAILURE_LIMIT = 6;
84
92
  const DEFAULT_WORKER_MAX_PARALLEL_TASKS = 5;
85
93
  const DEFAULT_WORKER_DEV_PORT_BASE = 8900;
86
94
  const CLAIM_BACKOFF_BASE_MS = 1000;
@@ -92,67 +100,13 @@ const SLACK_API_BASE_URL = 'https://slack.com/api';
92
100
  const WORKER_WRAPPER_CONTRACT_VERSION = 1;
93
101
  const WORKER_SUPPORTED_KINDS = ['NEW_GAME', 'REMIX_GAME', 'GAME_UPDATE', 'GAME_REVIEW'];
94
102
  const requireFromWorker = (0, node_module_1.createRequire)(__filename);
95
- const PLAYDROP_PLUGIN_DAEMON_GAME_CREATION_SKILL_PATH = 'skills/daemon-game-creation/SKILL.md';
96
- const PLAYDROP_PLUGIN_GAME_IMPROVEMENT_SKILL_PATH = 'skills/game-improvement/SKILL.md';
97
- const PLAYDROP_PLUGIN_ASSET_DISCOVERY_SKILL_PATH = 'skills/asset-discovery/SKILL.md';
98
- const PLAYDROP_PLUGIN_ASSET_EXTRACTION_SKILL_PATH = 'skills/asset-extraction-2d/SKILL.md';
99
- const PLAYDROP_PLUGIN_LISTING_ART_SKILL_PATH = 'skills/listing-art/SKILL.md';
100
- const PLAYDROP_PLUGIN_GAME_REVIEW_SKILL_PATH = 'skills/game-review/SKILL.md';
101
- const PLAYDROP_PLUGIN_ASSET_REUSE_REFERENCE_PATH = 'references/asset-reuse.md';
102
- const PLAYDROP_PLUGIN_ASSETS_AND_GENERATION_REFERENCE_PATH = 'references/assets-and-generation.md';
103
- const PLAYDROP_PLUGIN_CODE_REUSE_REFERENCE_PATH = 'references/code-reuse.md';
104
- const PLAYDROP_PLUGIN_EXTRACTOR_PATH = 'scripts/extract-alpha-background-swap.ts';
105
- const PLAYDROP_PLUGIN_LISTING_ICON_SCRIPT_PATH = 'scripts/compose-listing-icon.ts';
106
- const PLAYDROP_PLUGIN_LISTING_TITLE_SCRIPT_PATH = 'scripts/compose-listing-title.ts';
107
103
  const STAGED_PLAYDROP_PLUGIN_ROOT = '.playdrop/plugin';
108
- const PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES = [
109
- PLAYDROP_PLUGIN_DAEMON_GAME_CREATION_SKILL_PATH,
110
- PLAYDROP_PLUGIN_GAME_IMPROVEMENT_SKILL_PATH,
111
- PLAYDROP_PLUGIN_ASSET_DISCOVERY_SKILL_PATH,
112
- PLAYDROP_PLUGIN_ASSET_EXTRACTION_SKILL_PATH,
113
- PLAYDROP_PLUGIN_LISTING_ART_SKILL_PATH,
114
- PLAYDROP_PLUGIN_ASSET_REUSE_REFERENCE_PATH,
115
- PLAYDROP_PLUGIN_ASSETS_AND_GENERATION_REFERENCE_PATH,
116
- PLAYDROP_PLUGIN_CODE_REUSE_REFERENCE_PATH,
117
- PLAYDROP_PLUGIN_EXTRACTOR_PATH,
118
- PLAYDROP_PLUGIN_LISTING_ICON_SCRIPT_PATH,
119
- PLAYDROP_PLUGIN_LISTING_TITLE_SCRIPT_PATH,
120
- ];
121
- const PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES = [
122
- PLAYDROP_PLUGIN_GAME_REVIEW_SKILL_PATH,
123
- 'references/game-review-criteria.md',
124
- 'references/game-review-comparative-method.md',
125
- 'references/game-review-evidence-capture.md',
126
- 'references/game-review-rating-scale.md',
127
- 'references/game-review-score-caps.md',
128
- 'references/game-review-gates.md',
129
- 'references/game-review-gates/concept.md',
130
- 'references/game-review-gates/gameplay-prototype.md',
131
- 'references/game-review-gates/demo.md',
132
- 'references/game-review-gates/vertical-slice.md',
133
- 'references/game-review-gates/first-release.md',
134
- 'references/game-review-gates/mature-live-version.md',
135
- 'references/game-review-outcomes.md',
136
- 'references/game-review-feedback-format.md',
137
- 'references/game-review-dimensions/gameplay-core-loop.md',
138
- 'references/game-review-dimensions/depth-replayability.md',
139
- 'references/game-review-dimensions/controls-input.md',
140
- 'references/game-review-dimensions/ux-usability.md',
141
- 'references/game-review-dimensions/first-time-user-experience.md',
142
- 'references/game-review-dimensions/visuals-art-direction.md',
143
- 'references/game-review-dimensions/audio-feedback.md',
144
- 'references/game-review-dimensions/store-listing-metadata-accuracy.md',
145
- 'references/game-review-dimensions/safety-age-rating-compliance.md',
146
- 'references/game-review-dimensions/performance-stability.md',
147
- ];
148
- const PLAYDROP_PLUGIN_REFERENCE_FILES = [
149
- ...PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES,
150
- ...PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES,
151
- ];
104
+ const PLAYDROP_PLUGIN_STAGING_MANIFEST_PATH = 'playdrop-worker-staging.json';
152
105
  exports.WORKER_SESSION_EXPIRED_MESSAGE = 'worker session expired: run "playdrop auth login" and start the worker again';
153
106
  const WORKER_CONTEXT_ALLOWED_COMMANDS = [
154
107
  ['task', 'report'],
155
108
  ['task', 'report-catalogue'],
109
+ ['task', 'claim-slug'],
156
110
  ['task', 'upload'],
157
111
  ['task', 'done'],
158
112
  ['task', 'submit-review'],
@@ -175,7 +129,7 @@ const WORKER_CONTEXT_ALLOWED_COMMANDS = [
175
129
  ['ai', 'jobs', 'detail'],
176
130
  ['ai', 'generations'],
177
131
  ];
178
- exports.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = 'worker_context_command_not_allowed: inside a PlayDrop worker task only task progress, task upload/done/fail, assigned remix scaffolding, project validation/build/dev/capture, read-only catalogue/documentation lookup, and PlayDrop AI generation are permitted.';
132
+ exports.WORKER_CONTEXT_COMMAND_NOT_ALLOWED_MESSAGE = 'worker_context_command_not_allowed: inside a PlayDrop worker task only task progress, task claim-slug, task upload/done/fail, assigned app scaffolding, project validation/build/dev/capture, read-only catalogue/documentation lookup, and PlayDrop AI generation are permitted.';
179
133
  function allocateWorkerDevPort(input) {
180
134
  const basePort = input.basePort ?? DEFAULT_WORKER_DEV_PORT_BASE;
181
135
  const maxParallelTasks = input.maxParallelTasks ?? DEFAULT_WORKER_MAX_PARALLEL_TASKS;
@@ -203,7 +157,7 @@ function isWorkerContextCommandAllowed(argv) {
203
157
  if (WORKER_CONTEXT_ALLOWED_COMMANDS.some((allowed) => allowed.every((part, index) => argv[index] === part))) {
204
158
  return true;
205
159
  }
206
- return isAssignedRemixScaffoldCommandAllowed(argv);
160
+ return isAssignedAppScaffoldCommandAllowed(argv);
207
161
  }
208
162
  function readWorkerContextForPolicy() {
209
163
  try {
@@ -227,7 +181,7 @@ function normalizeRemixFlagValue(argv) {
227
181
  }
228
182
  return null;
229
183
  }
230
- function isAssignedRemixScaffoldCommandAllowed(argv) {
184
+ function isAssignedAppScaffoldCommandAllowed(argv) {
231
185
  if (argv[0] !== 'project' || argv[1] !== 'create' || argv[2] !== 'app') {
232
186
  return false;
233
187
  }
@@ -239,7 +193,12 @@ function isAssignedRemixScaffoldCommandAllowed(argv) {
239
193
  if (context?.kind !== 'REMIX_GAME'
240
194
  || !context.outputAppName
241
195
  || requestedAppName !== context.outputAppName) {
242
- return false;
196
+ if (context?.kind !== 'NEW_GAME'
197
+ || !context.outputAppName
198
+ || requestedAppName !== context.outputAppName) {
199
+ return false;
200
+ }
201
+ return normalizeRemixFlagValue(argv) === null;
243
202
  }
244
203
  return normalizeRemixFlagValue(argv) === context.remixSourceRef;
245
204
  }
@@ -465,6 +424,9 @@ async function stageWorkerTaskContext(input) {
465
424
  taskToken: input.taskContext.taskToken,
466
425
  }, null, 2), 'utf8');
467
426
  }
427
+ async function writeWorkerTaskContextFile(workspaceDir, taskContext) {
428
+ await (0, promises_1.writeFile)(workerTaskContextPath(workspaceDir), JSON.stringify(taskContext, null, 2), 'utf8');
429
+ }
468
430
  function findWorkspaceTaskFile(startDir, basename) {
469
431
  let current = node_path_1.default.resolve(startDir);
470
432
  while (true) {
@@ -598,6 +560,122 @@ function readTaskUploadResultFile(startDir = node_process_1.default.cwd()) {
598
560
  }
599
561
  return { taskId, appId, appVersionId, appName, version, versionNodeId, creatorUsername };
600
562
  }
563
+ const AGENT_TASK_NEXT_STEP_CATEGORY_SET = new Set(types_1.AGENT_TASK_NEXT_STEP_CATEGORIES);
564
+ const AGENT_TASK_CLAIM_SLUG_REGEX = /^[a-z0-9]+(?:[._-][a-z0-9]+)*$/;
565
+ const NEXT_STEPS_SCHEMA_HELP = [
566
+ 'Expected next-steps.json format:',
567
+ '{"nextSteps":[{"id":"tune-difficulty","title":"Tune difficulty","description":"Make the opening easier and add a sharper ramp.","prompt":"Tune the game difficulty so the first minute is approachable and the second minute becomes harder.","category":"MECHANICS"}]}',
568
+ `Allowed categories: ${types_1.AGENT_TASK_NEXT_STEP_CATEGORIES.join(', ')}`,
569
+ ];
570
+ const NEXT_STEPS_READ_FAILED = Symbol('NEXT_STEPS_READ_FAILED');
571
+ function normalizeTaskClaimSlugAppName(value) {
572
+ const appName = typeof value === 'string' ? value.trim() : '';
573
+ if (!appName || appName.length > 128 || !AGENT_TASK_CLAIM_SLUG_REGEX.test(appName)) {
574
+ throw new Error('invalid_slug_format');
575
+ }
576
+ return appName;
577
+ }
578
+ function normalizeTaskClaimSlugDisplayName(value) {
579
+ const displayName = typeof value === 'string' ? value.trim() : '';
580
+ if (!displayName || displayName.length > 120) {
581
+ throw new Error('invalid_display_name');
582
+ }
583
+ return displayName;
584
+ }
585
+ function readTaskNextStepsFile(filePath) {
586
+ const normalizedPath = typeof filePath === 'string' ? filePath.trim() : '';
587
+ if (!normalizedPath) {
588
+ return undefined;
589
+ }
590
+ const resolved = node_path_1.default.resolve(normalizedPath);
591
+ if (!(0, node_fs_1.existsSync)(resolved)) {
592
+ throw new Error('invalid_agent_task_next_steps_file');
593
+ }
594
+ let parsed;
595
+ try {
596
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(resolved, 'utf8'));
597
+ }
598
+ catch {
599
+ throw new Error('invalid_agent_task_next_steps_file');
600
+ }
601
+ let rawSteps = null;
602
+ if (Array.isArray(parsed)) {
603
+ rawSteps = parsed;
604
+ }
605
+ else if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
606
+ const object = parsed;
607
+ if (Array.isArray(object['nextSteps'])) {
608
+ rawSteps = object['nextSteps'];
609
+ }
610
+ }
611
+ if (!rawSteps) {
612
+ throw new Error('invalid_agent_task_next_steps');
613
+ }
614
+ if (rawSteps.length > 3) {
615
+ throw new Error('invalid_agent_task_next_steps_count');
616
+ }
617
+ const ids = new Set();
618
+ return rawSteps.map((entry) => {
619
+ const object = entry && typeof entry === 'object' && !Array.isArray(entry)
620
+ ? entry
621
+ : null;
622
+ if (!object) {
623
+ throw new Error('invalid_agent_task_next_step');
624
+ }
625
+ const id = typeof object['id'] === 'string' ? object['id'].trim() : '';
626
+ if (!id || id.length > 64 || !types_1.AGENT_TASK_NEXT_STEP_ID_REGEX.test(id)) {
627
+ throw new Error('invalid_agent_task_next_step_id');
628
+ }
629
+ if (ids.has(id)) {
630
+ throw new Error('invalid_agent_task_next_step_id_duplicate');
631
+ }
632
+ ids.add(id);
633
+ const title = typeof object['title'] === 'string' ? object['title'].trim() : '';
634
+ if (!title || title.length > 120) {
635
+ throw new Error('invalid_agent_task_next_step_title');
636
+ }
637
+ const description = typeof object['description'] === 'string' ? object['description'].trim() : '';
638
+ if (!description || description.length > 400) {
639
+ throw new Error('invalid_agent_task_next_step_description');
640
+ }
641
+ const prompt = typeof object['prompt'] === 'string' ? object['prompt'].trim() : '';
642
+ if (!prompt || prompt.length > 1200) {
643
+ throw new Error('invalid_agent_task_next_step_prompt');
644
+ }
645
+ const category = typeof object['category'] === 'string' ? object['category'].trim() : '';
646
+ if (!AGENT_TASK_NEXT_STEP_CATEGORY_SET.has(category)) {
647
+ throw new Error('invalid_agent_task_next_step_category');
648
+ }
649
+ return { id, title, description, prompt, category: category };
650
+ });
651
+ }
652
+ async function recordRejectedNextStepsFile(input) {
653
+ if (!input.ctx) {
654
+ return;
655
+ }
656
+ await input.ctx.client.workerCreateAgentTaskEvent(input.taskContext.taskId, {
657
+ taskToken: input.taskContext.taskToken,
658
+ kind: 'progress',
659
+ phase: 'next-steps',
660
+ message: `Rejected next-steps.json: ${input.code}`,
661
+ });
662
+ }
663
+ async function readTaskNextStepsFileForCommand(input) {
664
+ try {
665
+ return readTaskNextStepsFile(input.filePath);
666
+ }
667
+ catch (error) {
668
+ const code = error instanceof Error ? error.message : String(error);
669
+ await recordRejectedNextStepsFile({
670
+ ctx: input.ctx,
671
+ taskContext: input.taskContext,
672
+ code,
673
+ });
674
+ (0, messages_1.printErrorWithHelp)(code, NEXT_STEPS_SCHEMA_HELP, { command: input.command });
675
+ node_process_1.default.exitCode = 1;
676
+ return NEXT_STEPS_READ_FAILED;
677
+ }
678
+ }
601
679
  function assertTaskUploadResultMatchesContext(input) {
602
680
  if (input.uploadResult.taskId !== input.taskContext.taskId) {
603
681
  throw new Error('task_upload_result_task_mismatch');
@@ -693,8 +771,97 @@ function candidateVersionDirectories(root) {
693
771
  .sort((left, right) => right.localeCompare(left, undefined, { numeric: true }))
694
772
  .map((name) => node_path_1.default.join(root, name));
695
773
  }
696
- function hasPlaydropPluginReferences(pluginRoot) {
697
- return PLAYDROP_PLUGIN_REFERENCE_FILES.every((relativePath) => (0, node_fs_1.existsSync)(node_path_1.default.join(pluginRoot, relativePath)));
774
+ function isDirectory(candidatePath) {
775
+ try {
776
+ return (0, node_fs_1.statSync)(candidatePath).isDirectory();
777
+ }
778
+ catch {
779
+ return false;
780
+ }
781
+ }
782
+ function isFile(candidatePath) {
783
+ try {
784
+ return (0, node_fs_1.statSync)(candidatePath).isFile();
785
+ }
786
+ catch {
787
+ return false;
788
+ }
789
+ }
790
+ function looksLikePlaydropPluginRoot(pluginRoot) {
791
+ return isDirectory(pluginRoot) && ((0, node_fs_1.existsSync)(node_path_1.default.join(pluginRoot, PLAYDROP_PLUGIN_STAGING_MANIFEST_PATH))
792
+ || isDirectory(node_path_1.default.join(pluginRoot, 'skills'))
793
+ || isFile(node_path_1.default.join(pluginRoot, '.codex-plugin', 'plugin.json'))
794
+ || isFile(node_path_1.default.join(pluginRoot, '.claude-plugin', 'plugin.json'))
795
+ || isFile(node_path_1.default.join(pluginRoot, '.cursor-plugin', 'plugin.json')));
796
+ }
797
+ function normalizePlaydropPluginManifestPath(input) {
798
+ if (typeof input.value !== 'string' || input.value.trim().length <= 0) {
799
+ throw new Error(`playdrop_plugin_staging_manifest_incomplete:${input.kind}: file paths must be non-empty strings in ${input.manifestPath}`);
800
+ }
801
+ if (input.value.includes('\0') || input.value.includes('\\')) {
802
+ throw new Error(`playdrop_plugin_staging_manifest_invalid_path:${input.kind}:${input.value}`);
803
+ }
804
+ if (node_path_1.default.isAbsolute(input.value) || /^[A-Za-z]:[\\/]/.test(input.value)) {
805
+ throw new Error(`playdrop_plugin_staging_manifest_invalid_path:${input.kind}:${input.value}`);
806
+ }
807
+ const normalized = node_path_1.default.posix.normalize(input.value);
808
+ if (normalized === '.' || normalized === '..' || normalized.startsWith('../')) {
809
+ throw new Error(`playdrop_plugin_staging_manifest_invalid_path:${input.kind}:${input.value}`);
810
+ }
811
+ return normalized;
812
+ }
813
+ function readPlaydropPluginStagingManifest(pluginRoot) {
814
+ const resolvedRoot = node_path_1.default.resolve(pluginRoot);
815
+ const manifestPath = node_path_1.default.join(resolvedRoot, PLAYDROP_PLUGIN_STAGING_MANIFEST_PATH);
816
+ if (!isFile(manifestPath)) {
817
+ throw new Error(`playdrop_plugin_staging_manifest_missing: expected ${PLAYDROP_PLUGIN_STAGING_MANIFEST_PATH} under ${resolvedRoot}`);
818
+ }
819
+ let parsed;
820
+ try {
821
+ parsed = JSON.parse((0, node_fs_1.readFileSync)(manifestPath, 'utf8'));
822
+ }
823
+ catch (error) {
824
+ throw new Error(`playdrop_plugin_staging_manifest_invalid_json:${manifestPath}:${error instanceof Error ? error.message : String(error)}`);
825
+ }
826
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
827
+ throw new Error(`playdrop_plugin_staging_manifest_invalid:${manifestPath}`);
828
+ }
829
+ const rawManifest = parsed;
830
+ if (rawManifest.version !== 1) {
831
+ throw new Error(`playdrop_plugin_staging_manifest_unsupported_version:${manifestPath}`);
832
+ }
833
+ if (!rawManifest.taskFiles || typeof rawManifest.taskFiles !== 'object' || Array.isArray(rawManifest.taskFiles)) {
834
+ throw new Error(`playdrop_plugin_staging_manifest_incomplete: taskFiles missing in ${manifestPath}`);
835
+ }
836
+ const taskFiles = {};
837
+ const rawTaskFiles = rawManifest.taskFiles;
838
+ for (const kind of WORKER_SUPPORTED_KINDS) {
839
+ const rawFiles = rawTaskFiles[kind];
840
+ if (!Array.isArray(rawFiles) || rawFiles.length <= 0) {
841
+ throw new Error(`playdrop_plugin_staging_manifest_incomplete:${kind}: non-empty taskFiles entry required in ${manifestPath}`);
842
+ }
843
+ const seen = new Set();
844
+ const files = rawFiles.map((value) => normalizePlaydropPluginManifestPath({ manifestPath, kind, value }));
845
+ for (const relativePath of files) {
846
+ if (seen.has(relativePath)) {
847
+ throw new Error(`playdrop_plugin_staging_manifest_duplicate_file:${kind}:${relativePath}`);
848
+ }
849
+ seen.add(relativePath);
850
+ const source = node_path_1.default.resolve(resolvedRoot, ...relativePath.split('/'));
851
+ const relativeToRoot = node_path_1.default.relative(resolvedRoot, source);
852
+ if (relativeToRoot.startsWith('..') || node_path_1.default.isAbsolute(relativeToRoot)) {
853
+ throw new Error(`playdrop_plugin_staging_manifest_invalid_path:${kind}:${relativePath}`);
854
+ }
855
+ if (!isFile(source)) {
856
+ throw new Error(`playdrop_plugin_staging_manifest_file_missing:${kind}:${relativePath}`);
857
+ }
858
+ }
859
+ taskFiles[kind] = files;
860
+ }
861
+ return {
862
+ version: 1,
863
+ taskFiles,
864
+ };
698
865
  }
699
866
  function resolvePlaydropPluginRoot(input = {}) {
700
867
  const homeDir = input.homeDir ?? node_os_1.default.homedir();
@@ -702,9 +869,7 @@ function resolvePlaydropPluginRoot(input = {}) {
702
869
  const explicitRoot = env.PLAYDROP_WORKER_PLAYDROP_PLUGIN_DIR?.trim();
703
870
  if (explicitRoot) {
704
871
  const resolved = node_path_1.default.resolve(explicitRoot);
705
- if (!hasPlaydropPluginReferences(resolved)) {
706
- throw new Error(`playdrop_plugin_reference_missing: expected PlayDrop plugin skills and scripts under ${resolved}`);
707
- }
872
+ readPlaydropPluginStagingManifest(resolved);
708
873
  return resolved;
709
874
  }
710
875
  const candidates = [
@@ -716,27 +881,22 @@ function resolvePlaydropPluginRoot(input = {}) {
716
881
  ...candidateVersionDirectories(node_path_1.default.join(homeDir, '.codex', 'plugins', 'cache', 'playdrop', 'playdrop')),
717
882
  ...candidateVersionDirectories(node_path_1.default.join(homeDir, '.codex', 'plugins', 'cache', 'olivier-local-plugins', 'playdrop')),
718
883
  ];
719
- const pluginRoot = candidates.find(hasPlaydropPluginReferences);
720
- if (!pluginRoot) {
721
- throw new Error('playdrop_plugin_reference_missing: install or update the PlayDrop plugin so the required skills and scripts are available.');
884
+ for (const candidate of candidates) {
885
+ if (!looksLikePlaydropPluginRoot(candidate)) {
886
+ continue;
887
+ }
888
+ readPlaydropPluginStagingManifest(candidate);
889
+ return candidate;
722
890
  }
723
- return pluginRoot;
891
+ throw new Error(`playdrop_plugin_staging_manifest_missing: install or update the PlayDrop plugin so ${PLAYDROP_PLUGIN_STAGING_MANIFEST_PATH} is available.`);
724
892
  }
725
893
  async function stagePlaydropPluginReferences(input) {
726
894
  const pluginRoot = node_path_1.default.resolve(input.pluginRoot);
727
- if (!hasPlaydropPluginReferences(pluginRoot)) {
728
- throw new Error(`playdrop_plugin_reference_missing: expected required PlayDrop plugin skills and scripts under ${pluginRoot}`);
729
- }
730
- let referenceFiles;
731
- if (input.kind === 'GAME_REVIEW') {
732
- referenceFiles = PLAYDROP_PLUGIN_GAME_REVIEW_REFERENCE_FILES;
733
- }
734
- else if (input.kind === 'NEW_GAME' || input.kind === 'REMIX_GAME' || input.kind === 'GAME_UPDATE') {
735
- referenceFiles = PLAYDROP_PLUGIN_CREATION_REFERENCE_FILES;
736
- }
737
- else {
895
+ if (!WORKER_SUPPORTED_KINDS.includes(input.kind)) {
738
896
  throw new Error(`unsupported_agent_task_kind:${input.kind}`);
739
897
  }
898
+ const manifest = readPlaydropPluginStagingManifest(pluginRoot);
899
+ const referenceFiles = manifest.taskFiles[input.kind];
740
900
  const staged = [];
741
901
  for (const relativePath of referenceFiles) {
742
902
  const destinationRelativePath = node_path_1.default.join(STAGED_PLAYDROP_PLUGIN_ROOT, relativePath);
@@ -930,6 +1090,38 @@ function classifyWorkerEventDrainError(error) {
930
1090
  }
931
1091
  return null;
932
1092
  }
1093
+ function readErrorCauseCode(error) {
1094
+ const cause = error instanceof Error ? error.cause : null;
1095
+ if (cause && typeof cause === 'object' && 'code' in cause && typeof cause.code === 'string') {
1096
+ return cause.code;
1097
+ }
1098
+ return null;
1099
+ }
1100
+ function isWorkerTransientNetworkError(error) {
1101
+ if (!(error instanceof Error)) {
1102
+ return false;
1103
+ }
1104
+ const message = error.message.toLowerCase();
1105
+ if (message.includes('fetch failed') || message.includes('socket hang up')) {
1106
+ return true;
1107
+ }
1108
+ const causeCode = readErrorCauseCode(error);
1109
+ return causeCode
1110
+ ? ['ECONNRESET', 'ECONNREFUSED', 'EPIPE', 'ETIMEDOUT', 'UND_ERR_CONNECT_TIMEOUT'].includes(causeCode)
1111
+ : false;
1112
+ }
1113
+ function classifyWorkerHeartbeatError(error) {
1114
+ if (isWorkerAuthFailureError(error)) {
1115
+ return 'session_expired';
1116
+ }
1117
+ if (isLeaseInvalidError(error)) {
1118
+ return 'lease_invalid';
1119
+ }
1120
+ if (isWorkerTransientNetworkError(error)) {
1121
+ return 'transient_network';
1122
+ }
1123
+ return null;
1124
+ }
933
1125
  // Throws for claim errors that must stop the worker; returns for transient
934
1126
  // errors the claim loop may retry with backoff.
935
1127
  function assertWorkerClaimErrorRetryable(error) {
@@ -1349,10 +1541,15 @@ function normalizeQueuedWorkerEvent(value) {
1349
1541
  if (!kind) {
1350
1542
  throw new Error('agent_task_worker_event_kind_invalid');
1351
1543
  }
1352
- const message = typeof object.message === 'string' ? object.message.trim() : '';
1353
- if (!message) {
1354
- throw new Error('agent_task_worker_event_message_missing');
1355
- }
1544
+ const message = typeof object.message === 'string' && object.message.trim()
1545
+ ? object.message.trim()
1546
+ : null;
1547
+ const done = typeof object.done === 'string' && object.done.trim()
1548
+ ? object.done.trim()
1549
+ : null;
1550
+ const current = typeof object.current === 'string' && object.current.trim()
1551
+ ? object.current.trim()
1552
+ : null;
1356
1553
  const phase = typeof object.phase === 'string' && object.phase.trim()
1357
1554
  ? object.phase.trim()
1358
1555
  : null;
@@ -1365,10 +1562,16 @@ function normalizeQueuedWorkerEvent(value) {
1365
1562
  if (kind === 'catalogue_preview' && !payload) {
1366
1563
  throw new Error('agent_task_worker_event_payload_missing');
1367
1564
  }
1565
+ if (kind === 'catalogue_preview' && !message) {
1566
+ throw new Error('agent_task_worker_event_message_missing');
1567
+ }
1568
+ if (kind === 'progress' && !message && !done && !current) {
1569
+ throw new Error('agent_task_worker_event_message_missing');
1570
+ }
1368
1571
  if (kind === 'progress') {
1369
- return { kind, phase, message, pct };
1572
+ return { kind, phase, message, done, current, pct };
1370
1573
  }
1371
- return { kind, phase, message, pct, payload };
1574
+ return { kind, phase, message, done, current, pct, payload };
1372
1575
  }
1373
1576
  async function drainWorkerEventQueue(input) {
1374
1577
  if (!(0, node_fs_1.existsSync)(input.eventDir)) {
@@ -1386,8 +1589,10 @@ async function drainWorkerEventQueue(input) {
1386
1589
  leaseToken: input.leaseToken,
1387
1590
  kind: event.kind,
1388
1591
  phase: event.phase,
1389
- message: event.message,
1390
- pct: event.pct,
1592
+ ...(event.message ? { message: event.message } : {}),
1593
+ ...(event.done ? { done: event.done } : {}),
1594
+ ...(event.current ? { current: event.current } : {}),
1595
+ ...(event.pct !== null && event.pct !== undefined ? { pct: event.pct } : {}),
1391
1596
  ...(event.payload ? { payload: event.payload } : {}),
1392
1597
  });
1393
1598
  (0, node_fs_1.rmSync)(filePath, { force: true });
@@ -1443,7 +1648,9 @@ function resolveClaudeRuntimeModel(model) {
1443
1648
  return model;
1444
1649
  }
1445
1650
  async function runCodex(input) {
1446
- return await (0, runtime_1.runLoggedProcess)({
1651
+ let observedCodexThreadId = null;
1652
+ let codexThreadProbe = '';
1653
+ const result = await (0, runtime_1.runLoggedProcess)({
1447
1654
  command: 'codex',
1448
1655
  args: (0, runtime_1.buildCodexExecArgs)({
1449
1656
  workspaceDir: input.workspaceDir,
@@ -1464,10 +1671,41 @@ async function runCodex(input) {
1464
1671
  stdin: input.prompt,
1465
1672
  timeoutMs: (0, runtime_1.readPositiveEnvInt)('PLAYDROP_WORKER_CODEX_TIMEOUT_MS', runtime_1.DEFAULT_CODEX_TIMEOUT_MS),
1466
1673
  maxOutputChars: runtime_1.DEFAULT_CODEX_LOG_TAIL_CHARS,
1467
- onTranscriptChunks: input.onTranscriptChunks,
1674
+ onTranscriptChunks: async (chunks) => {
1675
+ if (!observedCodexThreadId) {
1676
+ codexThreadProbe = (codexThreadProbe + chunks.map((chunk) => chunk.content).join('')).slice(-64000);
1677
+ observedCodexThreadId = (0, runtime_1.extractCodexThreadId)(codexThreadProbe);
1678
+ }
1679
+ await input.onTranscriptChunks(chunks);
1680
+ },
1468
1681
  transcriptFlushIntervalMs: runtime_1.DEFAULT_TRANSCRIPT_FLUSH_INTERVAL_MS,
1469
1682
  onChild: input.onChild,
1470
1683
  });
1684
+ observedCodexThreadId = observedCodexThreadId
1685
+ ?? (0, runtime_1.extractCodexThreadId)(`${result.stdout}\n${result.stderr}\n${result.outputTail}`);
1686
+ if (!observedCodexThreadId) {
1687
+ if (result.tokenUsage.usageParseError) {
1688
+ console.warn('Codex thread id was not observed; rollout token usage could not be read.');
1689
+ }
1690
+ return result;
1691
+ }
1692
+ const rolloutUsage = await (0, runtime_1.readCodexRolloutTokenUsageForThread)(observedCodexThreadId);
1693
+ if (rolloutUsage.usageParseError || rolloutUsage.totalTokens === null) {
1694
+ if (result.tokenUsage.usageParseError) {
1695
+ console.warn(`Codex rollout token usage could not be read for thread ${observedCodexThreadId}: ${rolloutUsage.usageParseError ?? 'unknown_error'}.`);
1696
+ }
1697
+ return result;
1698
+ }
1699
+ if (!result.tokenUsage.usageParseError
1700
+ && result.tokenUsage.totalTokens !== null
1701
+ && result.tokenUsage.totalTokens !== rolloutUsage.totalTokens) {
1702
+ console.warn(`Codex stream token usage (${result.tokenUsage.totalTokens}) differed from rollout token usage (${rolloutUsage.totalTokens}); using rollout usage.`);
1703
+ }
1704
+ return {
1705
+ ...result,
1706
+ tokenUsage: rolloutUsage,
1707
+ tokensUsed: rolloutUsage.totalTokens,
1708
+ };
1471
1709
  }
1472
1710
  async function runClaude(input) {
1473
1711
  const denyReadRoots = resolveClaudeDenyReadRoots(input.workspaceDir);
@@ -1781,6 +2019,26 @@ function readPlaydropPluginVersion(pluginRoot) {
1781
2019
  }
1782
2020
  return null;
1783
2021
  }
2022
+ function readWorkerTargetUpdateFailure() {
2023
+ const failureFile = node_process_1.default.env.PLAYDROP_WORKER_TARGET_UPDATE_FAILURE_FILE?.trim() ?? '';
2024
+ if (!failureFile || !(0, node_fs_1.existsSync)(failureFile)) {
2025
+ return null;
2026
+ }
2027
+ const parsed = JSON.parse((0, node_fs_1.readFileSync)(failureFile, 'utf8'));
2028
+ const error = typeof parsed.error === 'string' ? parsed.error.trim() : '';
2029
+ const writtenAt = typeof parsed.writtenAt === 'string' ? parsed.writtenAt.trim() : '';
2030
+ if (!error || !writtenAt) {
2031
+ throw new Error('worker_update_target_failure_marker_invalid');
2032
+ }
2033
+ return {
2034
+ error,
2035
+ writtenAt,
2036
+ targetCliVersion: typeof parsed.targetCliVersion === 'string' ? parsed.targetCliVersion : null,
2037
+ minimumCliVersion: typeof parsed.minimumCliVersion === 'string' ? parsed.minimumCliVersion : null,
2038
+ targetPluginVersion: typeof parsed.targetPluginVersion === 'string' ? parsed.targetPluginVersion : null,
2039
+ minimumPluginVersion: typeof parsed.minimumPluginVersion === 'string' ? parsed.minimumPluginVersion : null,
2040
+ };
2041
+ }
1784
2042
  function collectWorkerLocalStatus() {
1785
2043
  const degradedReasons = [];
1786
2044
  let codexVersion = null;
@@ -1791,6 +2049,7 @@ function collectWorkerLocalStatus() {
1791
2049
  let pluginRoot = null;
1792
2050
  let pluginVersion = null;
1793
2051
  let pluginError = null;
2052
+ let targetUpdateFailure = null;
1794
2053
  try {
1795
2054
  codexVersion = probeCodexInstallation()?.codexVersion ?? null;
1796
2055
  }
@@ -1829,6 +2088,12 @@ function collectWorkerLocalStatus() {
1829
2088
  pluginError = errorMessage(error);
1830
2089
  degradedReasons.push(errorCode(error));
1831
2090
  }
2091
+ try {
2092
+ targetUpdateFailure = readWorkerTargetUpdateFailure();
2093
+ }
2094
+ catch (error) {
2095
+ degradedReasons.push(errorCode(error));
2096
+ }
1832
2097
  let capabilities = null;
1833
2098
  try {
1834
2099
  capabilities = buildWorkerCapabilities({
@@ -1849,6 +2114,9 @@ function collectWorkerLocalStatus() {
1849
2114
  agent.pluginVersion = pluginVersion;
1850
2115
  }
1851
2116
  }
2117
+ if (targetUpdateFailure) {
2118
+ capabilities.targetUpdateFailure = targetUpdateFailure;
2119
+ }
1852
2120
  for (const reason of capabilities.degradedReasons ?? []) {
1853
2121
  degradedReasons.push(reason);
1854
2122
  }
@@ -2103,23 +2371,42 @@ set -eu
2103
2371
  export PLAYDROP_WORKER_WRAPPER_CONTRACT_VERSION="${WORKER_WRAPPER_CONTRACT_VERSION}"
2104
2372
  POLICY_FILE="${policyFile}"
2105
2373
  FAILURE_FILE="${failureFile}"
2374
+ export PLAYDROP_WORKER_TARGET_UPDATE_FAILURE_FILE="$FAILURE_FILE"
2106
2375
  BACKOFF_SECONDS="\${PLAYDROP_WORKER_UPDATE_FAILURE_BACKOFF_SECONDS:-30}"
2376
+ TARGET_CLI_VERSION=""
2377
+ MINIMUM_CLI_VERSION=""
2378
+ TARGET_PLUGIN_VERSION=""
2379
+ MINIMUM_PLUGIN_VERSION=""
2380
+ TARGET_UPDATE_FAILED=0
2107
2381
 
2108
2382
  record_failure() {
2109
- node - "$FAILURE_FILE" "$1" <<'NODE'
2383
+ node - "$FAILURE_FILE" "$1" "$TARGET_CLI_VERSION" "$MINIMUM_CLI_VERSION" "$TARGET_PLUGIN_VERSION" "$MINIMUM_PLUGIN_VERSION" <<'NODE'
2110
2384
  const fs = require("fs");
2111
2385
  const path = require("path");
2112
2386
  const file = process.argv[2];
2113
2387
  const error = process.argv[3] || "worker_update_install_failed";
2388
+ const targetCliVersion = process.argv[4] || null;
2389
+ const minimumCliVersion = process.argv[5] || null;
2390
+ const targetPluginVersion = process.argv[6] || null;
2391
+ const minimumPluginVersion = process.argv[7] || null;
2114
2392
  fs.mkdirSync(path.dirname(file), { recursive: true });
2115
2393
  fs.writeFileSync(file, JSON.stringify({
2116
2394
  schemaVersion: 1,
2117
2395
  writtenAt: new Date().toISOString(),
2118
2396
  error,
2397
+ targetCliVersion,
2398
+ minimumCliVersion,
2399
+ targetPluginVersion,
2400
+ minimumPluginVersion,
2119
2401
  }, null, 2));
2120
2402
  NODE
2121
2403
  }
2122
2404
 
2405
+ record_target_failure() {
2406
+ TARGET_UPDATE_FAILED=1
2407
+ record_failure "worker_update_target_failed:$1"
2408
+ }
2409
+
2123
2410
  fail_converge() {
2124
2411
  code="$1"
2125
2412
  reason="$2"
@@ -2156,24 +2443,69 @@ read_policy_value() {
2156
2443
  node -e 'const fs = require("fs"); const file = process.argv[1]; const key = process.argv[2]; if (!fs.existsSync(file)) process.exit(0); const policy = JSON.parse(fs.readFileSync(file, "utf8")); const value = policy && policy[key]; if (typeof value === "string") process.stdout.write(value);' "$POLICY_FILE" "$1"
2157
2444
  }
2158
2445
 
2159
- validate_policy_file
2160
- TARGET_CLI_VERSION="$(read_policy_value targetCliVersion)"
2161
- TARGET_PLUGIN_VERSION="$(read_policy_value targetPluginVersion)"
2446
+ current_cli_version() {
2447
+ playdrop --version 2>/dev/null | node -e 'const fs = require("fs"); const input = fs.readFileSync(0, "utf8"); const match = input.match(/\\d+\\.\\d+\\.\\d+(?:[-+][0-9A-Za-z.-]+)?/); if (!match) process.exit(1); process.stdout.write(match[0]);'
2448
+ }
2162
2449
 
2163
- if [ -n "$TARGET_CLI_VERSION" ]; then
2164
- run_or_fail "worker_update_cli_install_failed" npm install -g --no-audit --no-fund "@playdrop/playdrop-cli@$TARGET_CLI_VERSION"
2165
- fi
2450
+ version_meets() {
2451
+ node - "$1" "$2" <<'NODE'
2452
+ const current = String(process.argv[2] || "").trim();
2453
+ const minimum = String(process.argv[3] || "").trim();
2454
+ if (!minimum) {
2455
+ process.exit(0);
2456
+ }
2457
+ if (!current) {
2458
+ process.exit(1);
2459
+ }
2460
+ function parse(version) {
2461
+ const match = version.match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
2462
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
2463
+ }
2464
+ const parsedCurrent = parse(current);
2465
+ const parsedMinimum = parse(minimum);
2466
+ if (!parsedCurrent || !parsedMinimum) {
2467
+ process.exit(current === minimum ? 0 : 1);
2468
+ }
2469
+ for (let index = 0; index < 3; index += 1) {
2470
+ if (parsedCurrent[index] > parsedMinimum[index]) {
2471
+ process.exit(0);
2472
+ }
2473
+ if (parsedCurrent[index] < parsedMinimum[index]) {
2474
+ process.exit(1);
2475
+ }
2476
+ }
2477
+ process.exit(0);
2478
+ NODE
2479
+ }
2166
2480
 
2167
- if [ -n "$TARGET_PLUGIN_VERSION" ]; then
2168
- if command -v codex >/dev/null 2>&1; then
2169
- run_or_fail "worker_update_plugin_install_failed:codex" playdrop agents update-plugin codex
2170
- fi
2171
- if command -v claude >/dev/null 2>&1; then
2172
- run_or_fail "worker_update_plugin_install_failed:claude" playdrop agents update-plugin claude
2173
- fi
2174
- node - "$TARGET_PLUGIN_VERSION" <<'NODE' || fail_converge "$?" "worker_update_plugin_version_mismatch"
2481
+ check_plugin_versions() {
2482
+ node - "$1" "$2" <<'NODE'
2175
2483
  const childProcess = require("child_process");
2176
- const targetVersion = process.argv[2];
2484
+ const requiredVersion = String(process.argv[2] || "").trim();
2485
+ const mode = String(process.argv[3] || "").trim();
2486
+ if (!requiredVersion) {
2487
+ process.exit(0);
2488
+ }
2489
+ function parse(version) {
2490
+ const match = version.match(/^(\\d+)\\.(\\d+)\\.(\\d+)(?:[-+][0-9A-Za-z.-]+)?$/);
2491
+ return match ? [Number(match[1]), Number(match[2]), Number(match[3])] : null;
2492
+ }
2493
+ function meetsMinimum(current, minimum) {
2494
+ const parsedCurrent = parse(current);
2495
+ const parsedMinimum = parse(minimum);
2496
+ if (!parsedCurrent || !parsedMinimum) {
2497
+ return current === minimum;
2498
+ }
2499
+ for (let index = 0; index < 3; index += 1) {
2500
+ if (parsedCurrent[index] > parsedMinimum[index]) {
2501
+ return true;
2502
+ }
2503
+ if (parsedCurrent[index] < parsedMinimum[index]) {
2504
+ return false;
2505
+ }
2506
+ }
2507
+ return true;
2508
+ }
2177
2509
  const result = childProcess.spawnSync("playdrop", ["agents", "status", "--json"], { encoding: "utf8" });
2178
2510
  if (result.status !== 0) {
2179
2511
  process.exit(1);
@@ -2188,11 +2520,55 @@ const agents = Array.isArray(parsed && parsed.agents) ? parsed.agents : [];
2188
2520
  const checked = agents.filter((agent) => agent && agent.id !== "cursor" && agent.cli && agent.cli.installed);
2189
2521
  for (const agent of checked) {
2190
2522
  const version = agent && agent.plugin && typeof agent.plugin.version === "string" ? agent.plugin.version.trim() : "";
2191
- if (version !== targetVersion) {
2523
+ if (mode === "target") {
2524
+ if (version !== requiredVersion) {
2525
+ process.exit(1);
2526
+ }
2527
+ } else if (!meetsMinimum(version, requiredVersion)) {
2192
2528
  process.exit(1);
2193
2529
  }
2194
2530
  }
2195
2531
  NODE
2532
+ }
2533
+
2534
+ validate_policy_file
2535
+ TARGET_CLI_VERSION="$(read_policy_value targetCliVersion)"
2536
+ MINIMUM_CLI_VERSION="$(read_policy_value minimumCliVersion)"
2537
+ TARGET_PLUGIN_VERSION="$(read_policy_value targetPluginVersion)"
2538
+ MINIMUM_PLUGIN_VERSION="$(read_policy_value minimumPluginVersion)"
2539
+
2540
+ if [ -n "$TARGET_CLI_VERSION" ]; then
2541
+ if npm install -g --no-audit --no-fund "@playdrop/playdrop-cli@$TARGET_CLI_VERSION"; then
2542
+ CURRENT_CLI_VERSION="$(current_cli_version)" || CURRENT_CLI_VERSION=""
2543
+ if [ "$CURRENT_CLI_VERSION" != "$TARGET_CLI_VERSION" ]; then
2544
+ record_target_failure "worker_update_cli_version_mismatch"
2545
+ fi
2546
+ else
2547
+ record_target_failure "worker_update_cli_install_failed"
2548
+ fi
2549
+ fi
2550
+
2551
+ if [ -n "$TARGET_PLUGIN_VERSION" ]; then
2552
+ if command -v codex >/dev/null 2>&1; then
2553
+ playdrop agents update-plugin codex || record_target_failure "worker_update_plugin_install_failed:codex"
2554
+ fi
2555
+ if command -v claude >/dev/null 2>&1; then
2556
+ playdrop agents update-plugin claude || record_target_failure "worker_update_plugin_install_failed:claude"
2557
+ fi
2558
+ check_plugin_versions "$TARGET_PLUGIN_VERSION" target || record_target_failure "worker_update_plugin_version_mismatch"
2559
+ fi
2560
+
2561
+ if [ -n "$MINIMUM_CLI_VERSION" ]; then
2562
+ CURRENT_CLI_VERSION="$(current_cli_version)" || fail_converge "$?" "worker_update_cli_below_minimum"
2563
+ version_meets "$CURRENT_CLI_VERSION" "$MINIMUM_CLI_VERSION" || fail_converge "$?" "worker_update_cli_below_minimum"
2564
+ fi
2565
+
2566
+ if [ -n "$MINIMUM_PLUGIN_VERSION" ]; then
2567
+ check_plugin_versions "$MINIMUM_PLUGIN_VERSION" minimum || fail_converge "$?" "worker_update_plugin_below_minimum"
2568
+ fi
2569
+
2570
+ if [ "$TARGET_UPDATE_FAILED" -eq 0 ]; then
2571
+ rm -f "$FAILURE_FILE"
2196
2572
  fi
2197
2573
 
2198
2574
  node - "$POLICY_FILE" <<'NODE' || fail_converge "$?" "worker_update_agent_cli_install_failed"
@@ -2711,6 +3087,7 @@ async function startWorker(options = {}) {
2711
3087
  let eventDrainTimer = null;
2712
3088
  let eventDrainPromise = Promise.resolve();
2713
3089
  let fenced = false;
3090
+ let heartbeatObservedFinalizedTask = false;
2714
3091
  let retainWorkspace = false;
2715
3092
  let workspaceDir = null;
2716
3093
  let availableSkillPaths = [];
@@ -2723,6 +3100,7 @@ async function startWorker(options = {}) {
2723
3100
  let agentCompletedAt = null;
2724
3101
  let agentResult = null;
2725
3102
  let telemetryReported = false;
3103
+ let heartbeatTransientFailures = 0;
2726
3104
  const reportTelemetry = async (status, setupError) => {
2727
3105
  if (!assignment) {
2728
3106
  return;
@@ -2836,19 +3214,49 @@ async function startWorker(options = {}) {
2836
3214
  heartbeatTimer = setInterval(() => {
2837
3215
  client.workerHeartbeatAgentTask(task.id, { workerKey, leaseToken })
2838
3216
  .then((heartbeat) => {
3217
+ heartbeatTransientFailures = 0;
2839
3218
  if (heartbeat.action === 'abort') {
2840
3219
  fenced = true;
2841
3220
  activeTerminators.get(task.id)?.();
2842
3221
  }
2843
3222
  })
2844
3223
  .catch((error) => {
2845
- if (isWorkerAuthFailureError(error)) {
3224
+ const classification = classifyWorkerHeartbeatError(error);
3225
+ if (classification === 'session_expired') {
2846
3226
  handleSessionExpiry();
2847
3227
  return;
2848
3228
  }
2849
- if (isLeaseInvalidError(error)) {
2850
- fenced = true;
2851
- activeTerminators.get(task.id)?.();
3229
+ if (classification === 'lease_invalid') {
3230
+ void (async () => {
3231
+ const refreshed = await fetchTaskDetail(client, target, task.id).catch(() => null);
3232
+ if (refreshed?.task.status === 'DONE') {
3233
+ fenced = true;
3234
+ if (!heartbeatObservedFinalizedTask) {
3235
+ heartbeatObservedFinalizedTask = true;
3236
+ console.error(`Agent task ${task.id} lease ended after task completed as DONE; waiting for agent exit.`);
3237
+ }
3238
+ return;
3239
+ }
3240
+ if (refreshed && refreshed.task.status !== 'RUNNING' && refreshed.task.status !== 'QUEUED') {
3241
+ console.error(`Agent task ${task.id} lease ended after task finalized as ${refreshed.task.status}; terminating agent child.`);
3242
+ }
3243
+ fenced = true;
3244
+ activeTerminators.get(task.id)?.();
3245
+ })().catch((heartbeatError) => {
3246
+ fenced = true;
3247
+ activeTerminators.get(task.id)?.();
3248
+ console.error(`Agent task lease check failed: ${heartbeatError instanceof Error ? heartbeatError.message : String(heartbeatError)}`);
3249
+ });
3250
+ return;
3251
+ }
3252
+ if (classification === 'transient_network') {
3253
+ heartbeatTransientFailures += 1;
3254
+ console.error(`Agent task heartbeat transient failure ${heartbeatTransientFailures}/${TASK_HEARTBEAT_TRANSIENT_FAILURE_LIMIT}: ${error instanceof Error ? error.message : String(error)}`);
3255
+ if (heartbeatTransientFailures >= TASK_HEARTBEAT_TRANSIENT_FAILURE_LIMIT) {
3256
+ fenced = true;
3257
+ activeTerminators.get(task.id)?.();
3258
+ console.error(`Agent task ${task.id} heartbeat could not reach the API; terminating agent child.`);
3259
+ }
2852
3260
  return;
2853
3261
  }
2854
3262
  console.error(`Agent task heartbeat failed: ${error instanceof Error ? error.message : String(error)}`);
@@ -3277,9 +3685,11 @@ async function startWorker(options = {}) {
3277
3685
  }, { env: options.env });
3278
3686
  }
3279
3687
  async function reportTask(options) {
3280
- const message = options.message?.trim();
3281
- if (!message) {
3282
- (0, messages_1.printErrorWithHelp)('Task report requires a message.', ['Example: playdrop task report --phase build --pct 25 -m "Building first loop"'], { command: 'task report' });
3688
+ const message = options.message?.trim() || null;
3689
+ const done = options.done?.trim() || null;
3690
+ const current = options.current?.trim() || null;
3691
+ if (!message && !done && !current) {
3692
+ (0, messages_1.printErrorWithHelp)('Task report requires a message, done, or current value.', ['Example: playdrop task report --phase build --done "Built the first loop" --current "Tuning controls"'], { command: 'task report' });
3283
3693
  node_process_1.default.exitCode = 1;
3284
3694
  return;
3285
3695
  }
@@ -3287,7 +3697,14 @@ async function reportTask(options) {
3287
3697
  const kind = normalizeTaskEventKind(options.kind);
3288
3698
  const phase = options.phase?.trim() || null;
3289
3699
  if (resolveWorkerEventQueueDir()) {
3290
- enqueueWorkerContextEvent({ kind, phase, message, pct });
3700
+ enqueueWorkerContextEvent({
3701
+ kind,
3702
+ phase,
3703
+ ...(message ? { message } : {}),
3704
+ ...(done ? { done } : {}),
3705
+ ...(current ? { current } : {}),
3706
+ ...(pct !== null ? { pct } : {}),
3707
+ });
3291
3708
  (0, output_1.printSuccess)('Task progress queued.');
3292
3709
  return;
3293
3710
  }
@@ -3384,6 +3801,34 @@ async function uploadTask(options = {}) {
3384
3801
  }
3385
3802
  (0, output_1.printSuccess)(`Uploaded ${published.creatorUsername}/${published.appName} version ${published.version}. Run "playdrop task done" to close the task.`);
3386
3803
  }
3804
+ async function claimSlugTask(options) {
3805
+ const taskContext = readTaskContextFile();
3806
+ if (taskContext.kind !== 'NEW_GAME' && taskContext.kind !== 'REMIX_GAME') {
3807
+ throw new Error('task_claim_slug_not_allowed_for_kind');
3808
+ }
3809
+ const appName = normalizeTaskClaimSlugAppName(options.appName);
3810
+ const displayName = normalizeTaskClaimSlugDisplayName(options.displayName);
3811
+ const ctx = await resolveTaskCommandContext('task claim-slug', options.env, taskContext);
3812
+ if (!ctx) {
3813
+ return;
3814
+ }
3815
+ const response = await ctx.client.workerClaimAgentTaskSlug(taskContext.taskId, {
3816
+ appName,
3817
+ displayName,
3818
+ });
3819
+ const claimedAppName = typeof response.appName === 'string' && response.appName.trim()
3820
+ ? response.appName.trim()
3821
+ : '';
3822
+ if (claimedAppName !== appName) {
3823
+ throw new Error('task_claim_slug_response_mismatch');
3824
+ }
3825
+ const workspaceDir = resolveTaskWorkspaceDir();
3826
+ await writeWorkerTaskContextFile(workspaceDir, {
3827
+ ...taskContext,
3828
+ outputAppName: claimedAppName,
3829
+ });
3830
+ (0, output_1.printSuccess)(`Claimed app slug "${claimedAppName}" for ${displayName}.`);
3831
+ }
3387
3832
  async function completeTask(options) {
3388
3833
  const taskContext = readTaskContextFile();
3389
3834
  if (taskContext.kind === 'GAME_REVIEW') {
@@ -3402,21 +3847,48 @@ async function completeTask(options) {
3402
3847
  if (uploadResult.taskId !== taskContext.taskId) {
3403
3848
  throw new Error('task_done_upload_result_task_mismatch');
3404
3849
  }
3405
- await ctx.client.workerCompleteAgentTask(taskContext.taskId, {
3406
- taskToken: taskContext.taskToken,
3407
- appId,
3408
- appVersionId,
3409
- result: {
3410
- appId: uploadResult.appId,
3411
- appVersionId: uploadResult.appVersionId,
3412
- version: uploadResult.version,
3413
- appName: uploadResult.appName,
3414
- versionNodeId: uploadResult.versionNodeId,
3415
- creatorUsername: uploadResult.creatorUsername,
3416
- completedBy: 'agent',
3417
- },
3850
+ const nextSteps = await readTaskNextStepsFileForCommand({
3851
+ filePath: options.nextSteps,
3852
+ command: 'task done',
3853
+ ctx,
3854
+ taskContext,
3418
3855
  });
3419
- (0, output_1.printSuccess)('Task marked done.');
3856
+ if (nextSteps === NEXT_STEPS_READ_FAILED) {
3857
+ return;
3858
+ }
3859
+ try {
3860
+ await ctx.client.workerCompleteAgentTask(taskContext.taskId, {
3861
+ taskToken: taskContext.taskToken,
3862
+ appId,
3863
+ appVersionId,
3864
+ result: {
3865
+ appId: uploadResult.appId,
3866
+ appVersionId: uploadResult.appVersionId,
3867
+ version: uploadResult.version,
3868
+ appName: uploadResult.appName,
3869
+ versionNodeId: uploadResult.versionNodeId,
3870
+ creatorUsername: uploadResult.creatorUsername,
3871
+ completedBy: 'agent',
3872
+ },
3873
+ ...(nextSteps !== undefined ? { nextSteps } : {}),
3874
+ });
3875
+ }
3876
+ catch (error) {
3877
+ if (error instanceof types_1.ApiError && error.status === 409 && error.code === 'remix_relation_missing') {
3878
+ await ctx.client.workerFailAgentTask(taskContext.taskId, {
3879
+ taskToken: taskContext.taskToken,
3880
+ error: 'remix_relation_missing',
3881
+ result: {
3882
+ appId: uploadResult.appId,
3883
+ appVersionId: uploadResult.appVersionId,
3884
+ appName: uploadResult.appName,
3885
+ version: uploadResult.version,
3886
+ },
3887
+ });
3888
+ }
3889
+ throw error;
3890
+ }
3891
+ (0, output_1.printSuccess)('Task marked done. Send one short final status sentence now, then stop.');
3420
3892
  }
3421
3893
  function normalizeReviewSubmitState(value) {
3422
3894
  const normalized = typeof value === 'string' ? value.trim().toUpperCase() : '';
@@ -3545,15 +4017,25 @@ async function submitReviewTask(options) {
3545
4017
  if (!ctx) {
3546
4018
  throw new Error('task_submit_review_auth_required');
3547
4019
  }
4020
+ const nextSteps = await readTaskNextStepsFileForCommand({
4021
+ filePath: options.nextSteps,
4022
+ command: 'task submit-review',
4023
+ ctx,
4024
+ taskContext,
4025
+ });
4026
+ if (nextSteps === NEXT_STEPS_READ_FAILED) {
4027
+ return;
4028
+ }
3548
4029
  await ctx.client.workerSubmitAgentTaskReview(taskContext.taskId, {
3549
4030
  taskToken: taskContext.taskToken,
3550
4031
  appVersionId: taskContext.reviewAppVersionId ?? 0,
3551
4032
  reviewState: state,
3552
4033
  reviewMessage,
3553
4034
  creatorFeedback,
4035
+ ...(nextSteps !== undefined ? { nextSteps } : {}),
3554
4036
  evidenceFiles,
3555
4037
  });
3556
- (0, output_1.printSuccess)('Review submitted.');
4038
+ (0, output_1.printSuccess)('Review submitted. Send one short final status sentence now, then stop.');
3557
4039
  }
3558
4040
  async function failTask(options) {
3559
4041
  const message = options.message?.trim();
@@ -3572,5 +4054,5 @@ async function failTask(options) {
3572
4054
  error: message,
3573
4055
  result: { failedBy: 'agent' },
3574
4056
  });
3575
- (0, output_1.printSuccess)('Task marked failed.');
4057
+ (0, output_1.printSuccess)('Task marked failed. Send one short final status sentence now, then stop.');
3576
4058
  }