@playcraft/cli 0.1.58-beta → 0.1.60-beta

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 (42) hide show
  1. package/dist/cli-root-help.js +1 -0
  2. package/dist/commands/references.js +55 -0
  3. package/dist/index.js +21 -3
  4. package/dist/references/client.js +69 -0
  5. package/dist/references/messages.js +57 -0
  6. package/dist/references/protocol.js +32 -0
  7. package/dist/utils/agent-generation-provenance.js +1 -1
  8. package/dist/workspace-runtime/batch/control.js +3 -3
  9. package/dist/workspace-runtime/batch/deliver.js +4 -3
  10. package/dist/workspace-runtime/batch/generate.js +4 -3
  11. package/dist/workspace-runtime/batch/install-log.js +49 -0
  12. package/dist/workspace-runtime/batch/policy.js +71 -22
  13. package/dist/workspace-runtime/batch/prepare.js +29 -13
  14. package/dist/workspace-runtime/batch/processes.js +86 -14
  15. package/dist/workspace-runtime/batch/writer-proof.js +2 -2
  16. package/dist/workspace-runtime/codex/app-server.js +305 -21
  17. package/dist/workspace-runtime/codex/generation-provenance-broker.js +17 -8
  18. package/dist/workspace-runtime/codex/launcher-client.js +34 -24
  19. package/dist/workspace-runtime/codex/loopback.js +2 -0
  20. package/dist/workspace-runtime/codex/model-broker.js +48 -8
  21. package/dist/workspace-runtime/codex/remix-references-broker.js +184 -0
  22. package/dist/workspace-runtime/codex/scheduled-task-tools.js +80 -0
  23. package/dist/workspace-runtime/serve.js +4 -1
  24. package/dist/workspace-runtime/server/directory-access.js +1 -1
  25. package/dist/workspace-runtime/server/dispatch.js +14 -6
  26. package/dist/workspace-runtime/server/http.js +43 -2
  27. package/dist/workspace-runtime/server/terminal-host.js +82 -1
  28. package/dist/workspace-runtime/server/workspace-storage.js +193 -0
  29. package/dist/workspace-runtime/workspaces/empty-template.js +2 -0
  30. package/dist/workspace-runtime/workspaces/registry.js +44 -3
  31. package/dist/workspace-runtime/workspaces/remix-reference-skill.js +126 -0
  32. package/dist/workspace-runtime/workspaces/scheduled-task-proposals.js +27 -0
  33. package/dist/workspace-runtime/workspaces/skills.js +3 -1
  34. package/dist/workspace-runtime/workspaces/storage-snapshot.js +235 -0
  35. package/package.json +3 -3
  36. package/project-template/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  37. package/project-template/.claude/skills/playcraft-runtime-parameters/SKILL.md +0 -2
  38. package/project-template-v2/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  39. package/project-template-v2/.playcraft/skills.lock.json +7 -0
  40. package/project-templates/empty/AGENTS.md +9 -0
  41. package/project-templates/phaser/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
  42. package/project-templates/threejs/.claude/skills/playcraft-remix-references/SKILL.md +45 -0
@@ -7,6 +7,7 @@ export function getCliTopicsHelpText() {
7
7
  ' 素材 tools generate-* | image <子命令> | audio <子命令> | prefab <子命令>',
8
8
  ' 素材库 library list/search/facets/get/files/download',
9
9
  ' 生成任务 assets estimate/generate/tasks/get/wait/cancel/download | assets animation',
10
+ ' Remix 参考 references list/search/import(发现参考并读取当前工程中的固定快照)',
10
11
  ' 视频理解 video models | video understand | video jobs get/cancel',
11
12
  ' 平台 build, build-base, build-playable, build-all, analyze, inspect, upgrade',
12
13
  ' tools 除 generate-* 外(Git / 沙箱构建 / 发布 / 列表 / Prefab 等,需后端 API)',
@@ -0,0 +1,55 @@
1
+ import { executeReferenceCommand } from '../references/client.js';
2
+ import { referenceErrorMessage, referenceMessage as message } from '../references/messages.js';
3
+ import { RemixReferenceToolError } from '../references/protocol.js';
4
+ export function referenceCommandFailure(action, code) {
5
+ return { schemaVersion: 'playcraft.references.v1', command: `references.${action}`, ok: false,
6
+ error: { code, message: referenceErrorMessage(code) } };
7
+ }
8
+ export function registerReferenceCommands(program) {
9
+ const references = program.command('references').description(message('description'));
10
+ const run = async (action, options, q) => {
11
+ try {
12
+ const result = await executeReferenceCommand({ action, cwd: process.cwd(),
13
+ ...(options.workspace ? { workspaceId: options.workspace } : {}),
14
+ ...(options.directory ? { directoryName: options.directory } : {}),
15
+ ...(action === 'search' ? { source: options.source, ...(q === undefined ? {} : { q }),
16
+ ...(options.tag?.length ? { tagCategory: options.tagCategory ?? 'Gameplay', tagValues: options.tag }
17
+ : options.tagCategory === undefined ? {} : { tagCategory: options.tagCategory }),
18
+ page: Number(options.page), limit: Number(options.limit) } : {}),
19
+ ...(action === 'import' ? { projectUuid: options.project, branchName: options.branch } : {}) });
20
+ const output = { schemaVersion: 'playcraft.references.v1', command: `references.${action}`, ...result };
21
+ if (options.json)
22
+ console.log(JSON.stringify(output));
23
+ else if (action === 'import') {
24
+ const reference = result.reference;
25
+ console.log(`${reference.name}\n${reference.resolvedPath}`);
26
+ }
27
+ else {
28
+ for (const item of result.items)
29
+ console.log(`${item.name}\t${item.resolvedPath ?? `${item.projectUuid}\t${item.branchName}`}`);
30
+ }
31
+ }
32
+ catch (error) {
33
+ const code = error instanceof RemixReferenceToolError ? error.code
34
+ : error instanceof Error && /^remix_reference_[a-z_]+$/.test(error.message) ? error.message : 'remix_reference_tool_failed';
35
+ const output = referenceCommandFailure(action, code);
36
+ if (options.json)
37
+ console.log(JSON.stringify(output));
38
+ else
39
+ console.error(output.error.message);
40
+ process.exitCode = 1;
41
+ }
42
+ };
43
+ const scope = (command) => command.option('--workspace <id>', message('workspace'))
44
+ .option('--directory <name>', message('directory')).option('--json', message('json'));
45
+ scope(references.command('list').description(message('list'))).action(options => run('list', options));
46
+ scope(references.command('search [query]').description(message('search'))
47
+ .option('--source <source>', message('source'), 'recommended')
48
+ .option('--tag <value>', message('tag'), (value, previous) => [...previous, value], [])
49
+ .option('--tag-category <name>', message('tagCategory'))
50
+ .option('--page <n>', message('page'), '1').option('--limit <n>', message('limit'), '12'))
51
+ .action((q, options) => run('search', options, q));
52
+ scope(references.command('import').description(message('import'))
53
+ .requiredOption('--project <uuid>', message('project')).requiredOption('--branch <name>', message('branch')))
54
+ .action(options => run('import', options));
55
+ }
package/dist/index.js CHANGED
@@ -32,6 +32,7 @@ import { registerPradCommands } from './commands/prad.js';
32
32
  import { register3DCommands } from './commands/3d.js';
33
33
  import { registerWorkspaceRuntimeCommands } from './commands/workspace-runtime.js';
34
34
  import { registerWorkspaceCommands } from './commands/workspace.js';
35
+ import { registerReferenceCommands, referenceCommandFailure } from './commands/references.js';
35
36
  import { registerVideoCommands } from './commands/video.js';
36
37
  import { registerPlayableCommands } from './commands/playable.js';
37
38
  import { registerPreviewRuntimeCommands } from './commands/preview-runtime.js';
@@ -44,7 +45,8 @@ const program = new Command();
44
45
  const structuredVideoTopic = ['video', 'playable'].includes(process.argv[2] ?? '');
45
46
  const assetTopic = ['assets', 'library'].includes(process.argv[2] ?? '');
46
47
  const registryTopic = process.argv[2] === 'skills' && process.argv[3] === 'registry';
47
- if (structuredVideoTopic)
48
+ const referenceTopic = process.argv[2] === 'references';
49
+ if (structuredVideoTopic || referenceTopic)
48
50
  program.exitOverride();
49
51
  program.name('playcraft').description(CLI_ROOT_DESCRIPTION).version(packageJson.version);
50
52
  // Registry upload commands use --version for the skill, after the command name.
@@ -53,7 +55,7 @@ if (registryTopic)
53
55
  registerRootProgramHelp(program);
54
56
  // 版本检查(非阻塞,后台执行)
55
57
  // 只在非 upgrade 命令时检查,避免重复提示
56
- if (!structuredVideoTopic && !assetTopic && !registryTopic && !process.argv.includes('upgrade') && !process.argv.includes('workspace-runtime')) {
58
+ if (!structuredVideoTopic && !assetTopic && !registryTopic && !referenceTopic && !process.argv.includes('upgrade') && !process.argv.includes('workspace-runtime')) {
57
59
  checkForUpdates(packageJson);
58
60
  }
59
61
  // create 命令
@@ -391,10 +393,26 @@ registerPradCommands(program);
391
393
  register3DCommands(program);
392
394
  registerWorkspaceRuntimeCommands(program);
393
395
  registerWorkspaceCommands(program);
396
+ registerReferenceCommands(program);
394
397
  registerVideoCommands(program);
395
398
  registerPlayableCommands(program);
396
399
  registerPreviewRuntimeCommands(program);
397
- if (structuredVideoTopic) {
400
+ if (referenceTopic) {
401
+ try {
402
+ await program.parseAsync(process.argv);
403
+ }
404
+ catch (error) {
405
+ if (!(error instanceof CommanderError))
406
+ throw error;
407
+ if (error.exitCode === 0)
408
+ process.exitCode = 0;
409
+ else {
410
+ console.log(JSON.stringify(referenceCommandFailure(process.argv[3] ?? 'unknown', 'invalid_remix_reference_tool_request')));
411
+ process.exitCode = 64;
412
+ }
413
+ }
414
+ }
415
+ else if (structuredVideoTopic) {
398
416
  try {
399
417
  await program.parseAsync(process.argv);
400
418
  }
@@ -0,0 +1,69 @@
1
+ import { lstat, readFile, realpath } from 'node:fs/promises';
2
+ import { basename, dirname, join } from 'node:path';
3
+ import { listWorkspaceRemixReferences } from '../workspace-runtime/workspaces/remix-references.js';
4
+ import { localRemixReference, RemixReferenceToolError, remixReferenceToolRequestSchema } from './protocol.js';
5
+ async function localRoot(cwd) {
6
+ let current = await realpath(cwd);
7
+ for (;;) {
8
+ if (await lstat(join(current, '.git')).catch(() => undefined)
9
+ || await lstat(join(current, '.playcraft/workspace-context.json')).catch(() => undefined)
10
+ || await lstat(join(current, '.playcraft/remix-references.json')).catch(() => undefined))
11
+ return current;
12
+ const parent = dirname(current);
13
+ if (parent === current)
14
+ return realpath(cwd);
15
+ current = parent;
16
+ }
17
+ }
18
+ async function localList(input) {
19
+ const root = await localRoot(input.cwd);
20
+ const directoryName = /^remix-[a-z0-9][a-z0-9-]{3,63}$/.test(basename(root)) ? basename(root) : 'basic';
21
+ if (input.directoryName !== undefined && input.directoryName !== directoryName)
22
+ throw new RemixReferenceToolError('remix_reference_scope_mismatch');
23
+ if (input.workspaceId !== undefined) {
24
+ const context = await readFile(join(root, '.playcraft/workspace-context.json'), 'utf8').then(value => JSON.parse(value)).catch(() => undefined);
25
+ if (context?.workspaceId !== input.workspaceId)
26
+ throw new RemixReferenceToolError('remix_reference_scope_mismatch');
27
+ }
28
+ const items = await listWorkspaceRemixReferences(root, directoryName);
29
+ return { ok: true, directoryName, items: items.map(item => localRemixReference(root, item)) };
30
+ }
31
+ /** The current shell receives only a local reference capability; control credentials stay in memory. */
32
+ export async function executeReferenceCommand(input) {
33
+ const parsed = remixReferenceToolRequestSchema.safeParse(input);
34
+ if (!parsed.success)
35
+ throw new RemixReferenceToolError('invalid_remix_reference_tool_request');
36
+ if (parsed.data.action === 'list')
37
+ return localList(parsed.data);
38
+ const endpoint = process.env.PLAYCRAFT_REMIX_REFERENCES_ENDPOINT;
39
+ const capability = process.env.PLAYCRAFT_REMIX_REFERENCES_CAPABILITY;
40
+ if (!endpoint && !capability) {
41
+ throw new RemixReferenceToolError('remix_reference_agent_required');
42
+ }
43
+ let url;
44
+ try {
45
+ url = new URL(endpoint ?? '');
46
+ if (!capability || capability.length > 128 || url.protocol !== 'http:' || url.hostname !== '127.0.0.1'
47
+ || !url.port || url.username || url.password || url.search || url.hash
48
+ || !/^\/[0-9a-f-]{36}\/[0-9a-f]{32}\/remix-references$/.test(url.pathname))
49
+ throw new Error();
50
+ }
51
+ catch {
52
+ throw new RemixReferenceToolError('workspace_remix_references_unavailable');
53
+ }
54
+ let response;
55
+ try {
56
+ response = await fetch(url, { method: 'POST', redirect: 'error', signal: AbortSignal.timeout(150_000),
57
+ headers: { authorization: `Bearer ${capability}`, 'content-type': 'application/json' }, body: JSON.stringify(parsed.data) });
58
+ }
59
+ catch {
60
+ throw new RemixReferenceToolError('workspace_remix_references_unavailable');
61
+ }
62
+ const value = await response.json().catch(() => undefined);
63
+ if (!response.ok || value?.ok !== true) {
64
+ const error = value?.error;
65
+ throw new RemixReferenceToolError(typeof error?.code === 'string' && /^[a-z_]{1,128}$/.test(error.code)
66
+ ? error.code : 'workspace_remix_references_unavailable');
67
+ }
68
+ return value;
69
+ }
@@ -0,0 +1,57 @@
1
+ const messages = {
2
+ en: {
3
+ description: 'Discover and import Remix source references for the current cloud workspace',
4
+ list: 'List verified local Remix references and their paths', search: 'Search accessible recommended or community Remixes',
5
+ import: 'Import a fixed Remix source snapshot into the current engineering directory',
6
+ query: 'Search names, branches, tags, creators or templates', source: 'recommended or community', project: 'Source project identifier', branch: 'Exact source branch',
7
+ tag: 'Match a tag value; repeat to match any of the values', tagCategory: 'Tag category; defaults to Gameplay when --tag is used',
8
+ workspace: 'Current cloud workspace identifier (must match this conversation)', directory: 'Current engineering directory (must match this conversation)',
9
+ page: 'Page number', limit: 'Maximum results, 1–50', json: 'Print JSON (JavaScript Object Notation, JavaScript 对象表示法)',
10
+ agentRequired: 'Search and import require a current Agent message in a supported cloud workspace. Reconnect or upgrade the workspace and retry in the Agent conversation.',
11
+ unavailable: 'Remix references are unavailable. Reconnect or upgrade the cloud workspace and retry.',
12
+ invalid: 'Invalid reference command arguments. Check the source, project, branch, workspace and directory.',
13
+ scope: 'The requested workspace or directory does not match the current Agent conversation.',
14
+ readonly: 'Import requires execute mode with write permission. Search and local listing remain available.',
15
+ turn: 'Cannot verify this command’s active Agent message. Wait for other conversations to finish, then retry from the current message.',
16
+ busy: 'Another conversation, production task or exclusive operation is using this directory. Retry after it finishes.',
17
+ sourceUnavailable: 'The selected Remix is unavailable or no longer accessible. Search again and choose an available result.',
18
+ tooLarge: 'The selected Remix exceeds the reference size or file limit.',
19
+ changed: 'The reference files changed or could not be verified. Restore the fixed snapshot or import a new version.',
20
+ failed: 'The Remix reference operation failed. Retry from the current Agent message.',
21
+ },
22
+ zh: {
23
+ description: '为当前云工作区发现并导入 Remix 源码参考',
24
+ list: '列出已验证的本地 Remix 参考及路径', search: '搜索可访问的推荐或社区 Remix', import: '把固定版本的 Remix 源码参考导入当前工程',
25
+ query: '搜索名称、分支、标签、创建者或模板', source: 'recommended(推荐)或 community(社区)', project: '来源项目标识', branch: '准确的来源分支名',
26
+ tag: '匹配的标签值;可重复指定,匹配任意一个值', tagCategory: '标签分类;使用 --tag 时默认为 Gameplay',
27
+ workspace: '当前云工作区标识(必须与本对话一致)', directory: '当前工程目录(必须与本对话一致)',
28
+ page: '页码', limit: '最多返回数量,1–50', json: '输出 JSON (JavaScript Object Notation, JavaScript 对象表示法)',
29
+ agentRequired: '搜索与导入需要受支持云工作区中的当前 Agent 消息。请重新连接或升级工作区,再在 Agent 对话中执行。',
30
+ unavailable: 'Remix 参考能力不可用,请重新连接或升级云工作区后重试。',
31
+ invalid: '参考命令参数无效,请检查来源、项目、分支、工作区及工程目录。',
32
+ scope: '指定的工作区或工程目录与当前 Agent 对话不一致。',
33
+ readonly: '导入需要执行模式及写入权限;仍可搜索和列出本地参考。',
34
+ turn: '无法核验该参考命令所属的当前 Agent 消息,请等待其他对话结束后,从当前消息重新执行。',
35
+ busy: '其他对话、生产任务或独占操作正在使用该工程,请等待结束后重试。',
36
+ sourceUnavailable: '所选 Remix 不可用或已无访问权限,请重新搜索并选择可用结果。',
37
+ tooLarge: '所选 Remix 超过参考文件数量或大小限制。',
38
+ changed: '参考文件已变化或无法通过校验,请恢复固定快照或导入新版本。',
39
+ failed: 'Remix 参考操作失败,请从当前 Agent 消息重试。',
40
+ },
41
+ };
42
+ export function referenceMessage(key) {
43
+ return messages[(process.env.PLAYCRAFT_LANGUAGE || process.env.LANG || 'en').startsWith('zh') ? 'zh' : 'en'][key];
44
+ }
45
+ export function referenceErrorMessage(code) {
46
+ const key = code === 'remix_reference_agent_required' ? 'agentRequired'
47
+ : code === 'remix_reference_scope_mismatch' ? 'scope'
48
+ : code === 'invalid_remix_reference_query' || code === 'invalid_remix_reference_import' || code === 'invalid_remix_reference_tool_request' ? 'invalid'
49
+ : code === 'remix_reference_readonly' || code === 'workspace_permission_denied' ? 'readonly'
50
+ : code === 'remix_reference_turn_unavailable' ? 'turn'
51
+ : ['turn_already_active', 'batch_workspace_readonly', 'directory_busy'].includes(code) ? 'busy'
52
+ : ['remix_reference_unavailable', 'remix_reference_source_changed', 'remix_reference_unsupported', 'remix_reference_incomplete', 'workspace_access_denied'].includes(code) ? 'sourceUnavailable'
53
+ : ['remix_reference_too_large', 'remix_reference_limit'].includes(code) ? 'tooLarge'
54
+ : ['remix_reference_changed', 'remix_reference_manifest_invalid', 'remix_reference_path_conflict', 'remix_reference_git_attributes'].includes(code) ? 'changed'
55
+ : code === 'workspace_remix_references_unavailable' ? 'unavailable' : 'failed';
56
+ return referenceMessage(key);
57
+ }
@@ -0,0 +1,32 @@
1
+ import { join } from 'node:path';
2
+ import { z } from 'zod';
3
+ const scope = {
4
+ cwd: z.string().min(1).max(4096),
5
+ workspaceId: z.string().uuid().optional(),
6
+ directoryName: z.string().regex(/^(basic|remix-[a-z0-9][a-z0-9-]{3,63})$/).optional(),
7
+ };
8
+ export const remixReferenceToolRequestSchema = z.discriminatedUnion('action', [
9
+ z.object({ ...scope, action: z.literal('list') }).strict(),
10
+ z.object({ ...scope, action: z.literal('search'), source: z.enum(['recommended', 'community']),
11
+ tagCategory: z.string().trim().min(1).max(128).optional(),
12
+ tagValues: z.array(z.string().trim().min(1).max(255)).min(1).max(20).optional(),
13
+ q: z.string().max(200).optional(), page: z.number().int().min(1).max(10000).default(1),
14
+ limit: z.number().int().min(1).max(50).default(12) }).strict(),
15
+ z.object({ ...scope, action: z.literal('import'), projectUuid: z.string().uuid(),
16
+ branchName: z.string().min(1).max(255) }).strict(),
17
+ ]).refine(input => input.action !== 'search' || (input.tagCategory === undefined) === (input.tagValues === undefined));
18
+ export class RemixReferenceToolError extends Error {
19
+ code;
20
+ constructor(code) {
21
+ super(code);
22
+ this.code = code;
23
+ this.name = 'RemixReferenceToolError';
24
+ }
25
+ }
26
+ export function localRemixReference(root, reference) {
27
+ const { files, omittedFiles, ...metadata } = reference;
28
+ return { ...metadata, source: { ...metadata.source,
29
+ recommended: metadata.source.recommended ?? metadata.source.category === 'recommended' },
30
+ resolvedPath: join(root, reference.path), fileCount: files.length,
31
+ omittedFileCount: omittedFiles?.length ?? 0 };
32
+ }
@@ -13,7 +13,7 @@ export async function agentGenerationProvenanceHeaders(path, body, requestId, cl
13
13
  return {};
14
14
  }
15
15
  const assetCreate = normalized === '/workspace-assets' && payload?.action === 'create';
16
- if (!isWorkspaceGenerationPath(normalized) && !assetCreate)
16
+ if (!isWorkspaceGenerationPath(normalized) && !assetCreate && normalized !== '/create-batch')
17
17
  return {};
18
18
  const endpoint = process.env.PLAYCRAFT_AGENT_GENERATION_ENDPOINT;
19
19
  const capability = process.env.PLAYCRAFT_AGENT_GENERATION_CAPABILITY;
@@ -1,12 +1,12 @@
1
1
  import { setTimeout as delay } from 'node:timers/promises';
2
2
  import { sandboxBatchMemberStopSchema } from '@playcraft/common/cloud-workspace/runtime';
3
- import { groupExited, stopProcessGroup } from './processes.js';
3
+ import { processHasExited, stopProcessGroup } from './processes.js';
4
4
  import { settleObservedGenerate } from './generate.js';
5
5
  import { memberStatus, readMember, serializeRun, writeMember, writeStopMarker } from './records.js';
6
6
  async function prove(record) {
7
7
  let stopped = true;
8
8
  for (const process of record.processes) {
9
- if (process.stopped || groupExited(process.pid))
9
+ if (processHasExited(process))
10
10
  process.stopped = true;
11
11
  else {
12
12
  process.stopped = await stopProcessGroup(process);
@@ -32,7 +32,7 @@ export async function readBatchMemberStatus(state, runId) {
32
32
  if (settled.state !== 'running')
33
33
  return memberStatus(settled);
34
34
  }
35
- const processes = record.processes.map(process => ({ ...process, stopped: process.stopped || groupExited(process.pid) }));
35
+ const processes = record.processes.map(process => ({ ...process, stopped: processHasExited(process) }));
36
36
  const live = processes.some(process => !process.stopped);
37
37
  let next = { ...record, processes };
38
38
  if (live && (record.writersStopped || record.state === 'completed' || record.state === 'stopped' || record.state === 'needs_user' || record.state === 'failed')) {
@@ -6,7 +6,7 @@ import { hashOperationPayload, sandboxBatchMemberDeliverSchema, } from '@playcra
6
6
  import { claimBatchDirectory, withDirectoryOwner } from '../server/directory-access.js';
7
7
  import { runDeveloperCommand } from '../workspaces/developer-command.js';
8
8
  import { workspaceRoot } from '../workspaces/registry.js';
9
- import { groupExited, runProcessGroup, stopProcessGroup } from './processes.js';
9
+ import { processHasExited, runProcessGroup, stopProcessGroup } from './processes.js';
10
10
  import { stopMemberWriters } from './writer-proof.js';
11
11
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember, } from './records.js';
12
12
  /** Base build writes dist/index.html. Channel packaging stays on export playable (`--mode full`). */
@@ -71,7 +71,7 @@ async function readBuiltHtml(root) {
71
71
  async function prove(record) {
72
72
  let stopped = true;
73
73
  for (const process of record.processes) {
74
- if (!(process.stopped || groupExited(process.pid))) {
74
+ if (!processHasExited(process)) {
75
75
  process.stopped = await stopProcessGroup(process);
76
76
  if (!process.stopped)
77
77
  stopped = false;
@@ -133,6 +133,7 @@ export async function deliverBatchMember(state, body) {
133
133
  processes: existing?.processes ?? [],
134
134
  sessionId: existing?.sessionId,
135
135
  operationId: existing?.operationId,
136
+ evidence: existing?.evidence,
136
137
  commandHash: { ...existing?.commandHash, deliver: hash },
137
138
  snapshotDigest: existing?.snapshotDigest,
138
139
  branchName: existing?.branchName,
@@ -184,7 +185,7 @@ export async function deliverBatchMember(state, body) {
184
185
  writersStopped: true,
185
186
  holding: false,
186
187
  error: undefined,
187
- evidence: { verified: true },
188
+ evidence: { ...record.evidence, verified: true },
188
189
  });
189
190
  return memberStatus(record);
190
191
  }
@@ -9,7 +9,7 @@ import { workspaceRoot } from '../workspaces/registry.js';
9
9
  import { snapshotWorkspaceTree } from '../workspaces/git.js';
10
10
  import { requireBatchContentChanges } from './content-changes.js';
11
11
  import { bindMemberPolicy, memberExecutionPolicy } from './policy.js';
12
- import { groupExited, stopProcessGroup } from './processes.js';
12
+ import { processHasExited, stopProcessGroup } from './processes.js';
13
13
  import { memberStatus, readMember, readStopMarker, serializeRun, writeMember } from './records.js';
14
14
  import { stopMemberWriters } from './writer-proof.js';
15
15
  function hashCommand(value) {
@@ -45,7 +45,7 @@ function threadIdOf(event) {
45
45
  async function proveStopped(record) {
46
46
  let stopped = true;
47
47
  for (const process of record.processes) {
48
- if (process.stopped || groupExited(process.pid))
48
+ if (processHasExited(process))
49
49
  process.stopped = true;
50
50
  else {
51
51
  const did = await stopProcessGroup(process);
@@ -86,7 +86,7 @@ export async function generateBatchMember(state, body) {
86
86
  // private
87
87
  await mkdir(tempDir, { recursive: true, mode: 0o700 });
88
88
  const policy = memberExecutionPolicy(root, tempDir);
89
- await bindMemberPolicy(state, sessionId, policy);
89
+ await bindMemberPolicy(state, sessionId, policy, { runId: command.runId, ownerFence: command.ownerFence });
90
90
  const modelConfigId = command.model && /^[0-9a-f-]{36}$/i.test(command.model) ? command.model : undefined;
91
91
  const turnPayload = { text: command.prompt, permissionMode: 'auto', ...(modelConfigId ? { modelConfigId } : {}) };
92
92
  const turnKey = operation(state, { kind: 'session.turn', clientRequestId: `batch-turn:${command.runId}`, sessionId, workspaceId: command.workspaceId, directoryName: command.directoryName, payload: turnPayload });
@@ -101,6 +101,7 @@ export async function generateBatchMember(state, body) {
101
101
  writersStopped: false, holding: true, stopRequested: false, processes: existing?.processes ?? [], sessionId,
102
102
  operationId: prior?.operationId, commandHash: { ...existing?.commandHash, generate: hash },
103
103
  snapshotDigest: existing?.snapshotDigest, branchName: existing?.branchName, headCommit: existing?.headCommit,
104
+ evidence: existing?.evidence,
104
105
  sourceCommit, beforeTree,
105
106
  updatedAt: new Date().toISOString(),
106
107
  });
@@ -0,0 +1,49 @@
1
+ import { mkdir, open } from 'node:fs/promises';
2
+ import { join } from 'node:path';
3
+ function redact(text) {
4
+ return text
5
+ .replace(/\x1b\[[0-9;]*m/g, '')
6
+ .replace(/(https?:\/\/[^\s?#]+)[?#][^\s]*/gi, '$1?[redacted]')
7
+ .replace(/([a-z][a-z0-9+.-]*:\/\/)[^\s/@]+@/gi, '$1[redacted]@')
8
+ .replace(/\bBearer\s+[^\s"']+/gi, 'Bearer [redacted]')
9
+ .replace(/\b(authorization|_authToken|api[_-]?key|password|secret|(?:access[_-]?|refresh[_-]?)?token)["']?\s*[:=]\s*(?:"[^"\r\n]*"|'[^'\r\n]*'|[^\s,;]+)/gi, '$1=[redacted]');
10
+ }
11
+ /** Stream complete lines so credentials split across output chunks are redacted before persistence. */
12
+ export async function createInstallLog(stateDir, runId, command) {
13
+ const directory = join(stateDir, 'batch-temp', runId);
14
+ // private: batch-temp is controller-owned diagnostics state, like the build log beside it.
15
+ await mkdir(directory, { recursive: true, mode: 0o700 });
16
+ const logPath = join(directory, 'install.log');
17
+ // private
18
+ const file = await open(logPath, 'a', 0o600);
19
+ let pending = Promise.resolve();
20
+ let partial = '';
21
+ let logTail = '';
22
+ const write = (text) => {
23
+ const safe = redact(text);
24
+ logTail = (logTail + safe).slice(-8192);
25
+ pending = pending.then(async () => { await file.write(safe); });
26
+ };
27
+ const append = (chunk) => {
28
+ partial += String(chunk);
29
+ const newline = partial.lastIndexOf('\n');
30
+ if (newline < 0)
31
+ return;
32
+ write(partial.slice(0, newline + 1));
33
+ partial = partial.slice(newline + 1);
34
+ };
35
+ write(`\n[install] ${new Date().toISOString()}\n$ ${command}\n`);
36
+ return {
37
+ append,
38
+ async finish(exitCode) {
39
+ write(partial + `\n[install] exit code=${exitCode ?? 'unknown'}\n`);
40
+ try {
41
+ await pending;
42
+ }
43
+ finally {
44
+ await file.close();
45
+ }
46
+ return { exitCode, logPath, logTail };
47
+ },
48
+ };
49
+ }
@@ -1,4 +1,4 @@
1
- import { readFileSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
1
+ import { readFileSync, readdirSync, mkdirSync, writeFileSync, renameSync } from 'node:fs';
2
2
  import { randomUUID } from 'node:crypto';
3
3
  import { dirname, join } from 'node:path';
4
4
  const memory = new WeakMap();
@@ -29,38 +29,87 @@ function parsePolicy(raw) {
29
29
  return undefined;
30
30
  return value;
31
31
  }
32
- export async function bindMemberPolicy(state, sessionId, policy) {
32
+ export async function bindMemberPolicy(state, sessionId, policy, owner) {
33
33
  const path = policyPath(state, sessionId);
34
34
  mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
35
35
  const temporary = `${path}.${randomUUID()}.tmp`;
36
- writeFileSync(temporary, JSON.stringify(policy), { mode: 0o600 });
36
+ const binding = { ...owner, policy };
37
+ writeFileSync(temporary, JSON.stringify(binding), { mode: 0o600 });
37
38
  renameSync(temporary, path);
38
39
  const map = memory.get(state) ?? new Map();
39
- map.set(sessionId, policy);
40
+ map.set(sessionId, binding);
40
41
  memory.set(state, map);
41
42
  }
42
- /** Synchronous: the shared session adapter reapplies this on every thread start and resume. */
43
+ function readBinding(raw) {
44
+ const value = JSON.parse(raw);
45
+ if (!('policy' in value)) {
46
+ const policy = parsePolicy(raw);
47
+ return policy ? { policy } : undefined;
48
+ }
49
+ const policy = parsePolicy(JSON.stringify(value.policy));
50
+ return policy && typeof value.runId === 'string' && /^[0-9a-f-]{36}$/i.test(value.runId)
51
+ && Number.isInteger(value.ownerFence) && value.ownerFence >= 0 ? { ...value, policy } : undefined;
52
+ }
53
+ function memberRecord(state, runId, sessionId) {
54
+ if (typeof runId !== 'string' || !/^[0-9a-f-]{36}$/i.test(runId))
55
+ throw new Error('member_policy_record_invalid');
56
+ const record = JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', `${runId}.json`), 'utf8'));
57
+ if (record.runId !== runId || record.sessionId !== sessionId || !Number.isInteger(record.ownerFence)
58
+ || typeof record.holding !== 'boolean' || typeof record.writersStopped !== 'boolean')
59
+ throw new Error('member_policy_record_invalid');
60
+ return record;
61
+ }
62
+ function sessionMembers(state, sessionId) {
63
+ return readdirSync(join(state.config.stateDir, 'batch-members')).filter(name => name.endsWith('.json'))
64
+ .map(name => JSON.parse(readFileSync(join(state.config.stateDir, 'batch-members', name), 'utf8')))
65
+ .filter(record => record.sessionId === sessionId)
66
+ .map(record => memberRecord(state, record.runId, sessionId));
67
+ }
68
+ /** Re-read the owning Run on every lookup; a cached policy is never proof of an active member. */
43
69
  export function trustedMemberPolicy(state, sessionId) {
44
- const cached = memory.get(state)?.get(sessionId);
45
- if (cached)
46
- return cached;
47
70
  try {
48
- const parsed = parsePolicy(readFileSync(policyPath(state, sessionId), 'utf8'));
49
- if (!parsed) {
50
- return {
51
- cwd: state.config.workspaceRoot,
52
- approvalPolicy: 'never',
53
- sandboxPolicy: { type: 'readOnly', networkAccess: false },
54
- };
71
+ let binding = memory.get(state)?.get(sessionId);
72
+ if (!binding) {
73
+ let raw;
74
+ try {
75
+ raw = readFileSync(policyPath(state, sessionId), 'utf8');
76
+ }
77
+ catch (error) {
78
+ if (error.code === 'ENOENT')
79
+ return undefined;
80
+ throw error;
81
+ }
82
+ binding = readBinding(raw);
83
+ if (!binding)
84
+ throw new Error('member_policy_invalid');
85
+ const map = memory.get(state) ?? new Map();
86
+ map.set(sessionId, binding);
87
+ memory.set(state, map);
55
88
  }
56
- const map = memory.get(state) ?? new Map();
57
- map.set(sessionId, parsed);
58
- memory.set(state, map);
59
- return parsed;
60
- }
61
- catch (error) {
62
- if (error.code === 'ENOENT')
89
+ if (binding.runId) {
90
+ const record = memberRecord(state, binding.runId, sessionId);
91
+ if (record.holding || !record.writersStopped) {
92
+ if (record.ownerFence !== binding.ownerFence)
93
+ throw new Error('member_policy_owner_mismatch');
94
+ return binding.policy;
95
+ }
96
+ if (sessionMembers(state, sessionId).some(member => member.holding || !member.writersStopped))
97
+ throw new Error('member_policy_owner_mismatch');
98
+ return undefined;
99
+ }
100
+ // Profiles written before Run ownership was recorded still use durable member facts.
101
+ const records = sessionMembers(state, sessionId);
102
+ if (!records.length)
103
+ throw new Error('member_policy_record_missing');
104
+ const active = records.filter(record => record.holding || !record.writersStopped);
105
+ if (!active.length)
63
106
  return undefined;
107
+ if (active.length !== 1 || binding.policy.sandboxPolicy.type !== 'workspaceWrite'
108
+ || !binding.policy.sandboxPolicy.writableRoots.includes(join(state.config.stateDir, 'batch-temp', active[0].runId)))
109
+ throw new Error('member_policy_owner_mismatch');
110
+ return binding.policy;
111
+ }
112
+ catch {
64
113
  return {
65
114
  cwd: state.config.workspaceRoot,
66
115
  approvalPolicy: 'never',
@@ -10,7 +10,8 @@ import { workspaceContainerRoot } from '../workspaces/registry.js';
10
10
  import { dependencyInstallCommand, planInstall } from '../workspaces/dependencies.js';
11
11
  import { copyCredentialHelpers, gitRead, gitWrite } from './git.js';
12
12
  import { restoreArchive } from './source-capture.js';
13
- import { groupExited, runProcessGroup } from './processes.js';
13
+ import { processHasExited, runProcessGroup } from './processes.js';
14
+ import { createInstallLog } from './install-log.js';
14
15
  import { stopMemberWriters } from './writer-proof.js';
15
16
  import { filesystemObjectStore, memberStatus, readMember, readSnapshot, readStopMarker, serializeRun, sourceCacheDir, writeMember, } from './records.js';
16
17
  const BRANCH = /^remix\/draft-[a-f0-9]{16}$/;
@@ -143,7 +144,7 @@ export async function prepareBatchMember(state, body, options) {
143
144
  existing.commandHash?.prepare &&
144
145
  existing.commandHash.prepare !== hash)
145
146
  throw new Error('batch_command_conflict');
146
- if (existing?.processes.some((process) => process.role === 'install' && !process.stopped && !groupExited(process.pid)))
147
+ if (existing?.processes.some((process) => process.role === 'install' && !processHasExited(process)))
147
148
  return memberStatus(existing);
148
149
  const snapshot = command.snapshot;
149
150
  await claimBatchDirectory(state, command);
@@ -204,27 +205,42 @@ export async function prepareBatchMember(state, body, options) {
204
205
  await writeWorkspaceCliContext(root, command.workspaceId);
205
206
  const plan = await planInstall(root);
206
207
  if (plan.kind === 'install') {
207
- // Keep control-user installs script-free. pnpm validates the frozen manifest and lock.
208
- const frozen = `${dependencyInstallCommand(state, { frozenLockfile: true })} --ignore-scripts`;
208
+ // Keep preparation script-free and avoid hardlinks to legacy control-owned cache files.
209
+ const flags = '--package-import-method=copy --ignore-scripts';
210
+ const frozen = `${dependencyInstallCommand(state, { frozenLockfile: true })} ${flags}`;
209
211
  const installCommand = process.env.PLAYCRAFT_BATCH_INSTALL_COMMAND ?? (plan.hasLockfile
210
212
  ? frozen
211
- : `${dependencyInstallCommand(state)} --lockfile-only --ignore-scripts && ${frozen}`);
212
- const installed = await runProcessGroup(root, installCommand, 'install', async (process) => {
213
- record = await writeMember(state, {
214
- ...record,
215
- processes: [...record.processes, process],
216
- });
217
- });
213
+ : `${dependencyInstallCommand(state)} --lockfile-only ${flags} && ${frozen}`);
214
+ const log = await createInstallLog(state.config.stateDir, command.runId, installCommand);
215
+ let installed;
216
+ try {
217
+ installed = await runProcessGroup(root, installCommand, 'install', async (process) => {
218
+ record = {
219
+ ...record,
220
+ processes: [...record.processes.filter(item => process.launcher
221
+ ? item.launcher?.processId !== process.launcher.processId
222
+ : item.pid !== process.pid), process],
223
+ };
224
+ record = await writeMember(state, record);
225
+ }, { state, processId: `batch-install-${command.runId}-${randomUUID()}`, onOutput: log.append });
226
+ }
227
+ catch (error) {
228
+ log.append(`\n[install] ${error instanceof Error ? error.message : String(error)}\n`);
229
+ }
230
+ const installation = await log.finish(installed?.code ?? null);
218
231
  record = await writeMember(state, {
219
232
  ...record,
220
- processes: record.processes.map((process) => process.pid === installed.process.pid
233
+ evidence: { ...record.evidence, installation },
234
+ processes: record.processes.map((process) => installed && process.pid === installed.process.pid
221
235
  ? { ...installed.process, role: 'install' }
222
236
  : process),
223
237
  });
224
238
  if (await readStopMarker(state, command.runId))
225
239
  return memberStatus(await sealStopped(state, command, record));
226
- if (installed.code !== 0)
240
+ if (!installed || installed.code !== 0 || (installed.process.launcher && !installed.process.stopped)) {
241
+ console.warn(JSON.stringify({ event: 'batch_install_failed', runId: command.runId, ...installation }));
227
242
  throw new Error('batch_install_failed');
243
+ }
228
244
  }
229
245
  const writersStopped = await stopMemberWriters(state, record);
230
246
  if (!writersStopped)