@playcraft/cli 0.1.27 → 0.1.28

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.
@@ -18,21 +18,32 @@ function asRecord(value) {
18
18
  ? value
19
19
  : {};
20
20
  }
21
- function waitForChildExit(child) {
22
- if (child.exitCode !== null || child.signalCode !== null)
21
+ function waitForChildExit(child, processGroup = false) {
22
+ if ((child.exitCode !== null || child.signalCode !== null) && child.stdout?.destroyed && child.stderr?.destroyed)
23
23
  return Promise.resolve();
24
- return new Promise((resolve) => {
24
+ return new Promise((resolve, reject) => {
25
+ let drainTimer;
25
26
  const done = () => {
26
27
  clearTimeout(timer);
28
+ clearTimeout(drainTimer);
27
29
  resolve();
28
30
  };
29
31
  const timer = setTimeout(() => {
30
- if (child.exitCode === null && child.signalCode === null)
32
+ if (processGroup && child.pid) {
33
+ try {
34
+ process.kill(-child.pid, 'SIGKILL');
35
+ }
36
+ catch { /* The group may have exited already. */ }
37
+ }
38
+ else if (child.exitCode === null && child.signalCode === null)
31
39
  child.kill('SIGKILL');
40
+ drainTimer = setTimeout(() => {
41
+ child.off('close', done);
42
+ reject(new Error('codex_stdio_stop_unconfirmed'));
43
+ }, 3000);
32
44
  }, 3_000);
33
- child.once('exit', done);
34
- if (child.exitCode !== null || child.signalCode !== null)
35
- done();
45
+ // A process exit can precede its final stdout data; close follows the drained pipes.
46
+ child.once('close', done);
36
47
  });
37
48
  }
38
49
  function spawnCommand(bin) {
@@ -54,6 +65,7 @@ export function createCodexAppServerAdapter(options) {
54
65
  let rpc;
55
66
  let readyFlag = false;
56
67
  let closed = false;
68
+ let closing;
57
69
  let startPromise;
58
70
  let catalogDirectory;
59
71
  let skillsRestartFailed = false;
@@ -186,8 +198,8 @@ export function createCodexAppServerAdapter(options) {
186
198
  if (previous)
187
199
  previous.superseded = true;
188
200
  readyFlag = false;
189
- await previous?.rpc.close();
190
201
  await previous?.child.stop();
202
+ await previous?.rpc.close();
191
203
  if (closed)
192
204
  throw new Error('codex_not_started');
193
205
  rpc = undefined;
@@ -217,8 +229,8 @@ export function createCodexAppServerAdapter(options) {
217
229
  skillsRestartFailed = true;
218
230
  if (live) {
219
231
  live.superseded = true;
220
- await live.rpc.close();
221
232
  await live.child.stop();
233
+ await live.rpc.close();
222
234
  }
223
235
  throw new NativePreparationError('workspace_skills_unavailable');
224
236
  }
@@ -267,11 +279,15 @@ export function createCodexAppServerAdapter(options) {
267
279
  sandbox_mode: 'danger-full-access',
268
280
  }))
269
281
  args.push('-c', `${key}=${JSON.stringify(value)}`);
282
+ // Prepare isolated Codex profiles as the development identity before replacing the shell.
283
+ const argv = process.platform === 'linux' && command === '/usr/local/bin/codex'
284
+ ? ['/usr/bin/bash', '-c', 'umask 077; mkdir -p -- "$CODEX_HOME" && exec "$@"', 'playcraft-codex', command, ...args]
285
+ : [command, ...args];
270
286
  child = await startLauncherProcess({
271
287
  socketPath: launcherSocket,
272
288
  processId: `codex-${randomUUID()}`,
273
289
  runtimeEpoch: executionEpoch,
274
- argv: [command, ...args],
290
+ argv,
275
291
  // The launcher admits the dev home, not the shared /workspaces root.
276
292
  // Each thread supplies its registered workspace separately.
277
293
  cwd: options.config.workspaceMode === 'multi' ? '/home/playcraft-dev' : options.config.workspaceRoot,
@@ -292,7 +308,7 @@ export function createCodexAppServerAdapter(options) {
292
308
  await new Promise((resolve, reject) => { local.once('spawn', resolve); local.once('error', reject); });
293
309
  child = {
294
310
  pid: local.pid, stdin: local.stdin, stdout: local.stdout, stderr: local.stderr,
295
- onExit(listener) { local.once('exit', listener); },
311
+ onExit(listener) { local.once('close', listener); },
296
312
  async stop() {
297
313
  if (local.exitCode === null && local.signalCode === null) {
298
314
  if (options.processGroup && local.pid) {
@@ -304,7 +320,7 @@ export function createCodexAppServerAdapter(options) {
304
320
  else
305
321
  local.kill('SIGTERM');
306
322
  }
307
- await waitForChildExit(local);
323
+ await waitForChildExit(local, options.processGroup);
308
324
  },
309
325
  };
310
326
  }
@@ -381,8 +397,8 @@ export function createCodexAppServerAdapter(options) {
381
397
  }
382
398
  catch (error) {
383
399
  current.superseded = true;
384
- await current.rpc.close();
385
400
  await current.child.stop();
401
+ await current.rpc.close();
386
402
  throw error;
387
403
  }
388
404
  }
@@ -412,9 +428,9 @@ export function createCodexAppServerAdapter(options) {
412
428
  startPromise = undefined;
413
429
  threads.clear();
414
430
  approvals.clear();
415
- await previous?.rpc.close().catch(() => undefined);
416
431
  if (previous)
417
432
  await previous.child.stop();
433
+ await previous?.rpc.close().catch(() => undefined);
418
434
  executionEpoch += 1;
419
435
  skillsRestartFailed = false;
420
436
  if (closed)
@@ -611,26 +627,27 @@ export function createCodexAppServerAdapter(options) {
611
627
  }));
612
628
  return { outcome: 'accepted', approvalId: input.approvalId, turnId: pending.turnId };
613
629
  },
614
- async close() {
615
- if (closed)
616
- return;
617
- closed = true;
618
- readyFlag = false;
619
- if (live)
620
- live.superseded = true;
621
- const current = child;
622
- await rpc?.close().catch(() => undefined);
623
- rpc = undefined;
624
- if (current)
625
- await current.stop();
626
- child = undefined;
627
- live = undefined;
628
- await options.modelBroker?.close();
629
- if (catalogDirectory)
630
- await rm(catalogDirectory, { recursive: true, force: true });
631
- for (const directory of coordinatorDirectories.values())
632
- await rm(directory, { recursive: true, force: true });
633
- coordinatorDirectories.clear();
630
+ close() {
631
+ closing ??= (async () => {
632
+ closed = true;
633
+ readyFlag = false;
634
+ if (live)
635
+ live.superseded = true;
636
+ const current = child, currentRpc = rpc;
637
+ if (current)
638
+ await current.stop();
639
+ await currentRpc?.close().catch(() => undefined);
640
+ rpc = undefined;
641
+ child = undefined;
642
+ live = undefined;
643
+ await options.modelBroker?.close();
644
+ if (catalogDirectory)
645
+ await rm(catalogDirectory, { recursive: true, force: true });
646
+ for (const directory of coordinatorDirectories.values())
647
+ await rm(directory, { recursive: true, force: true });
648
+ coordinatorDirectories.clear();
649
+ })();
650
+ return closing;
634
651
  },
635
652
  };
636
653
  }
@@ -43,6 +43,8 @@ export async function startLauncherProcess(input) {
43
43
  let buffer = '';
44
44
  let identity;
45
45
  let stopPromise;
46
+ let finishTransport;
47
+ const transportClosed = new Promise(resolve => { finishTransport = resolve; });
46
48
  const stdin = new Writable({
47
49
  write(chunk, _encoding, callback) {
48
50
  socket.write(`${JSON.stringify({ action: 'stdin', data: chunk.toString('base64') })}\n`, callback);
@@ -122,6 +124,7 @@ export async function startLauncherProcess(input) {
122
124
  stderr.end();
123
125
  stdin.destroy();
124
126
  exit();
127
+ finishTransport();
125
128
  });
126
129
  });
127
130
  return {
@@ -149,7 +152,18 @@ export async function startLauncherProcess(input) {
149
152
  while (Date.now() < deadline) {
150
153
  const status = await request(input.socketPath, { ...identity, action: 'status' });
151
154
  if (status.running === false) {
152
- socket.destroy();
155
+ let timeout;
156
+ try {
157
+ await Promise.race([transportClosed, new Promise((_resolve, reject) => {
158
+ timeout = setTimeout(() => {
159
+ socket.destroy();
160
+ reject(new Error('launcher_output_drain_unconfirmed'));
161
+ }, 3000);
162
+ })]);
163
+ }
164
+ finally {
165
+ clearTimeout(timeout);
166
+ }
153
167
  return;
154
168
  }
155
169
  await delay(50);
@@ -1,6 +1,8 @@
1
1
  import { join } from 'node:path';
2
2
  import { lstat, mkdir } from 'node:fs/promises';
3
+ import { hashOperationPayload, operationScopeKey, productionSessionId, transitionOperation } from '@playcraft/common/cloud-workspace/runtime';
3
4
  import { createCodexAppServerAdapter } from '../codex/app-server.js';
5
+ import { ingestCollected, publishSourceEvents, recordNativeEvents } from '../server/dispatch.js';
4
6
  import { verifyProductionLease } from './storage.js';
5
7
  import { processBirth, stopProductionGroup } from './processes.js';
6
8
  import { openDirectoryToDevelopment } from '../workspaces/directory-access.js';
@@ -42,16 +44,74 @@ export async function runProductionNative(state, input, root, record, track) {
42
44
  await record(group);
43
45
  },
44
46
  });
45
- track(() => adapter.close());
46
- const sessionId = `production-${input.task.attemptId}`;
47
+ const sessionId = productionSessionId(input.task.attemptId);
48
+ const prompt = String(input.payload.prompt ?? '');
49
+ const now = new Date().toISOString();
50
+ const operation = {
51
+ operationId: `${input.task.attemptId}:generate`, clientRequestId: `${input.task.attemptId}:generate`,
52
+ environment: input.task.environment, userId: input.task.userId, workspaceId: input.task.workspaceId,
53
+ directoryName: input.directoryName, sessionId, kind: 'session.turn', status: 'dispatched',
54
+ generation: state.config.generation, runtimeEpoch: state.runtimeEpoch,
55
+ payloadHash: hashOperationPayload({ text: prompt }), createdAt: now, updatedAt: now,
56
+ };
57
+ const scopeKey = operationScopeKey(operation);
58
+ let collecting = Promise.resolve();
59
+ let notifying;
60
+ let pendingNotifications = [];
61
+ const notify = (events) => {
62
+ // Notifications only wake the source archive; every event already belongs to the durable journal.
63
+ pendingNotifications = [...pendingNotifications, ...events].slice(-100);
64
+ notifying ??= (async () => {
65
+ while (pendingNotifications.length) {
66
+ const page = pendingNotifications;
67
+ pendingNotifications = [];
68
+ await publishSourceEvents(state, page);
69
+ }
70
+ })().finally(() => {
71
+ notifying = undefined;
72
+ if (pendingNotifications.length)
73
+ notify([]);
74
+ });
75
+ };
76
+ let archiveError;
77
+ let shutdownError;
78
+ let cancelled = false;
79
+ let rootThreadId;
80
+ let rootTurnId;
81
+ let completedTurn;
82
+ let timeout;
83
+ let leaseCheck;
47
84
  let completion;
48
85
  const done = new Promise(resolve => { completion = resolve; });
86
+ track(async () => {
87
+ cancelled = true;
88
+ completion('unknown');
89
+ await adapter.close?.();
90
+ await collecting;
91
+ });
49
92
  const unsubscribe = adapter.onCollected?.(event => {
50
93
  if (event.kind === 'process_exit')
51
94
  completion('unknown');
95
+ else {
96
+ const params = event.params && typeof event.params === 'object'
97
+ ? event.params : {};
98
+ // The prompt is recorded before dispatch; Codex may echo the same user message afterward.
99
+ if (params.item?.type !== 'userMessage' || params.threadId !== rootThreadId) {
100
+ collecting = collecting.then(async () => notify(await ingestCollected(state, {
101
+ ...event, params: { ...params, directoryName: input.directoryName },
102
+ }, false))).catch(error => {
103
+ archiveError ??= error;
104
+ completion('unknown');
105
+ });
106
+ }
107
+ }
52
108
  if (event.method === 'turn/completed') {
53
109
  const value = event.params;
54
- completion(value.turn?.status === 'completed' ? 'completed' : 'failed');
110
+ if (value.threadId === rootThreadId && value.turn?.id) {
111
+ completedTurn = value.turn;
112
+ if (value.turn.id === rootTurnId)
113
+ completion(value.turn.status === 'completed' ? 'completed' : 'failed');
114
+ }
55
115
  }
56
116
  });
57
117
  try {
@@ -63,28 +123,72 @@ export async function runProductionNative(state, input, root, record, track) {
63
123
  groups.push(group);
64
124
  await record(group);
65
125
  const thread = await adapter.startThread({ sessionId, cwd: root });
66
- const turn = await adapter.startTurn({ sessionId, threadId: thread.threadId, text: String(input.payload.prompt ?? ''),
67
- clientRequestId: `${input.task.attemptId}:generate`, persistReservation: async () => undefined });
126
+ rootThreadId = thread.threadId;
127
+ // A production thread lives in its own Codex home and must never resume through the main adapter.
128
+ await state.journal.putSession({ sessionId, workspaceId: input.task.workspaceId, directoryName: input.directoryName,
129
+ threadId: thread.threadId, cwd: root, recoverability: 'history_only', turnStatus: 'starting' });
130
+ const creation = { ...operation, operationId: `${input.task.attemptId}:session.create`,
131
+ clientRequestId: `${input.task.attemptId}:session.create`, kind: 'session.create', status: 'completed',
132
+ payloadHash: hashOperationPayload({}), nativeThreadId: thread.threadId };
133
+ await state.journal.putOperation(operationScopeKey(creation), creation);
134
+ await state.journal.putOperation(scopeKey, { ...operation, nativeThreadId: thread.threadId });
135
+ notify(await recordNativeEvents(state, creation, [{ type: 'session.created', occurredAt: now, payload: {
136
+ sessionId, threadId: thread.threadId, batchId: input.task.batchId, attemptId: input.task.attemptId,
137
+ taskId: input.task.taskId, readonly: true, recoverability: 'history_only',
138
+ } }], false));
139
+ notify(await recordNativeEvents(state, operation, [{ type: 'item/completed', occurredAt: now, payload: {
140
+ item: { id: `${sessionId}-prompt`, type: 'userMessage', content: [{ type: 'text', text: prompt }] },
141
+ } }], false));
142
+ const turn = await adapter.startTurn({ sessionId, threadId: thread.threadId, text: prompt,
143
+ clientRequestId: operation.clientRequestId, persistReservation: async ({ nativeRequestId }) => {
144
+ await state.journal.putOperation(scopeKey, { ...operation, nativeThreadId: thread.threadId, nativeRequestId });
145
+ } });
68
146
  if (turn.outcome !== 'accepted')
69
- throw new Error('production_native_start_unknown');
70
- const timeout = setTimeout(() => completion('unknown'), 30 * 60_000);
147
+ throw new Error(turn.outcome === 'failed' ? 'production_native_failed' : 'production_native_start_unknown');
148
+ rootTurnId = turn.turnId;
149
+ const reserved = state.journal.getOperation(scopeKey);
150
+ const released = await state.journal.bindTurn({ scopeKey, sessionId, threadId: thread.threadId, turnId: turn.turnId,
151
+ operation: transitionOperation(reserved, 'accepted', { nativeThreadId: thread.threadId, nativeTurnId: turn.turnId }) });
152
+ if (released.length)
153
+ notify(released);
154
+ if (completedTurn?.id === rootTurnId)
155
+ completion(completedTurn.status === 'completed' ? 'completed' : 'failed');
156
+ timeout = setTimeout(() => completion('unknown'), 30 * 60_000);
71
157
  let checking = false;
72
- const leaseCheck = setInterval(() => {
158
+ leaseCheck = setInterval(() => {
73
159
  if (checking)
74
160
  return;
75
161
  checking = true;
76
162
  void verifyProductionLease(input).catch(async () => { completion('unknown'); await adapter.close?.().catch(() => undefined); }).finally(() => { checking = false; });
77
163
  }, 5000);
78
164
  const status = await done;
79
- clearInterval(leaseCheck);
80
- clearTimeout(timeout);
81
165
  if (status !== 'completed')
82
166
  throw new Error(status === 'failed' ? 'production_native_failed' : 'production_native_unknown');
83
167
  }
84
168
  finally {
169
+ clearInterval(leaseCheck);
170
+ clearTimeout(timeout);
171
+ await adapter.close?.().catch(error => { shutdownError = error; });
85
172
  unsubscribe?.();
86
- await adapter.close?.().catch(() => undefined);
173
+ await collecting;
87
174
  for (const group of groups)
88
175
  await record({ ...group, stopped: await stopProductionGroup(group) });
176
+ const latest = state.journal.getOperation(scopeKey);
177
+ if (latest && ['dispatched', 'accepted', 'uncertain'].includes(latest.status)) {
178
+ const status = cancelled ? 'interrupted' : 'failed';
179
+ await state.journal.putOperation(scopeKey, transitionOperation(latest, status, {
180
+ error: cancelled ? 'production_execution_stopped' : 'production_native_unknown',
181
+ }));
182
+ const session = state.journal.getSession(sessionId);
183
+ if (session) {
184
+ await state.journal.putSession({ ...session, turnStatus: status, turnBlock: undefined });
185
+ notify(await recordNativeEvents(state, latest, [{ type: `turn.${status}`, occurredAt: new Date().toISOString(),
186
+ payload: { turnId: latest.nativeTurnId, error: cancelled ? 'production_execution_stopped' : 'production_native_unknown' } }], false));
187
+ }
188
+ }
189
+ if (archiveError)
190
+ throw new Error('production_chat_archive_failed');
191
+ if (shutdownError)
192
+ throw new Error('production_native_stop_unknown');
89
193
  }
90
194
  }
@@ -2,7 +2,7 @@ import { withObservedFile } from '../workspaces/revisions.js';
2
2
  import { revisionScope, sessionWorkspaceRoot, workspaceRoot, workspaceSessions } from '../workspaces/registry.js';
3
3
  import { join } from 'node:path';
4
4
  import { z } from 'zod';
5
- import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
5
+ import { PLATFORM_ROUTES, platformEndpoint, fileWritePayloadSchema, gitCommitPayloadSchema, gitPathsPayloadSchema, gitPushPayloadSchema, parameterPatchPayloadSchema, productionSessionAttemptId, reasoningEffortSchema, terminalCreatePayloadSchema, terminalProcessIdentity, transitionOperation, workspaceSavePayloadSchema, workspaceSkillsUpdatePayloadSchema, } from '@playcraft/common/cloud-workspace/runtime';
6
6
  import { closeCliCredentials } from '../workspaces/cli-credentials.js';
7
7
  import { artifactFormat } from '../workspaces/artifact-files.js';
8
8
  import { refreshWorkspaceArtifacts } from '../workspaces/artifact-events.js';
@@ -62,6 +62,9 @@ export async function executeOperation(state, scopeKey, operation, payload, reco
62
62
  }
63
63
  async function executeScopedOperation(state, scopeKey, operation, payload, recovery) {
64
64
  let current = await persist(state, scopeKey, transitionOperation(operation, 'dispatched'));
65
+ if (operation.kind.startsWith('session.') && operation.sessionId && productionSessionAttemptId(operation.sessionId)) {
66
+ return persist(state, scopeKey, transitionOperation(current, 'failed', { error: 'production_session_readonly' }));
67
+ }
65
68
  if (operation.kind === 'runtime.ensure') {
66
69
  current = await persist(state, scopeKey, transitionOperation(current, 'accepted'));
67
70
  return persist(state, scopeKey, transitionOperation(current, 'completed'));
@@ -807,7 +810,7 @@ export function attachNativeCollector(state) {
807
810
  void ingestCollected(state, event);
808
811
  });
809
812
  }
810
- async function ingestCollected(state, event) {
813
+ export async function ingestCollected(state, event, publish = true) {
811
814
  const ids = nativeIds(event.params);
812
815
  const recorded = await state.journal.ingestNative({
813
816
  runtimeId: state.config.runtimeId,
@@ -821,10 +824,11 @@ async function ingestCollected(state, event) {
821
824
  itemId: ids.itemId,
822
825
  occurredAt: event.occurredAt,
823
826
  });
824
- if (recorded.length > 0)
827
+ if (publish && recorded.length > 0)
825
828
  await publishSourceEvents(state, recorded);
829
+ return recorded;
826
830
  }
827
- export async function recordNativeEvents(state, operation, events) {
831
+ export async function recordNativeEvents(state, operation, events, publish = true) {
828
832
  const recorded = [];
829
833
  for (const event of events) {
830
834
  const payload = {
@@ -845,11 +849,11 @@ export async function recordNativeEvents(state, operation, events) {
845
849
  operationId: operation.operationId,
846
850
  }));
847
851
  }
848
- if (recorded.length > 0)
852
+ if (publish && recorded.length > 0)
849
853
  await publishSourceEvents(state, recorded);
850
854
  return recorded;
851
855
  }
852
- async function publishSourceEvents(state, events) {
856
+ export async function publishSourceEvents(state, events) {
853
857
  await notifyCallback(state, events).catch(() => undefined);
854
858
  }
855
859
  function sessionTurnBusy(session) {
@@ -870,6 +874,7 @@ function nativeIds(params) {
870
874
  async function notifyCallback(state, events) {
871
875
  await fetch(platformEndpoint(state.config.publicBaseUrl, PLATFORM_ROUTES.events), {
872
876
  method: 'POST',
877
+ signal: AbortSignal.timeout(5000),
873
878
  headers: {
874
879
  authorization: `Bearer ${state.config.controlToken}`,
875
880
  'x-playcraft-control-token': state.config.controlToken,
@@ -1,7 +1,7 @@
1
1
  import { readObservedFile, withObservedFile } from '../workspaces/revisions.js';
2
2
  import { registerWorkspace, revisionScope, sessionWorkspaceRoot, workspaceRoot, WorkspaceContextError } from '../workspaces/registry.js';
3
3
  import { randomUUID } from 'node:crypto';
4
- import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
4
+ import { workspaceRegistrationRequestSchema, HTTP_STATUS_BY_ERROR, assertLeaseFence, buildHandshakeDocument, capability, findUntrustedRuntimeFields, gitFileRefSchema, hashOperationPayload, isLostThreadError, sourceAckSchema, operationAcceptRequestSchema, operationKindSchema, operationScopeKey, productionSessionAttemptId, isWorkspaceMutation, resolveIdempotentAccept, nativeHistoryReadSchema, runtimeError, runtimeConnectivityDocumentSchema, unavailableRuntimeCapabilities, } from '@playcraft/common/cloud-workspace/runtime';
5
5
  import { controlTokenMatches, extractControlToken } from './auth.js';
6
6
  import { applyRuntimeEndpointPatch } from './endpoints.js';
7
7
  import { canExecuteKind, executeOperation } from './dispatch.js';
@@ -365,6 +365,8 @@ export async function handleRuntimeRequest(state, request, response) {
365
365
  return;
366
366
  }
367
367
  if (request.method === 'POST' && url.pathname === '/runtime/operations') {
368
+ // Track creation before accepting it, so a new unconnected terminal is known not to have spawned.
369
+ terminalHost(state);
368
370
  await acceptOperation(state, request, response);
369
371
  return;
370
372
  }
@@ -527,6 +529,10 @@ async function acceptOperation(state, request, response) {
527
529
  writeJson(response, 200, decision.operation);
528
530
  return;
529
531
  }
532
+ if (parsed.data.kind.startsWith('session.') && parsed.data.sessionId && productionSessionAttemptId(parsed.data.sessionId)) {
533
+ writeError(response, runtimeError('capability_unavailable', 'production_session_readonly'));
534
+ return;
535
+ }
530
536
  if (parsed.data.kind === 'session.create') {
531
537
  const creation = state.journal.findSessionCreation(parsed.data.sessionId);
532
538
  const session = state.journal.getSession(parsed.data.sessionId);
@@ -702,15 +708,13 @@ async function handleTerminalIdentity(state, request, response, remainder) {
702
708
  return;
703
709
  }
704
710
  if (request.method === 'DELETE' && !action) {
705
- const destroyed = terminal.status === 'destroyed'
706
- ? terminal
707
- : await state.journal.putTerminal({
708
- ...terminal,
709
- status: 'destroyed',
710
- destroyedAt: new Date().toISOString(),
711
- });
712
- terminalHost(state).revoke(destroyed.terminalId, 'terminal_destroyed');
713
- writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
711
+ try {
712
+ const destroyed = await terminalHost(state).destroy(terminalId);
713
+ writeJson(response, 200, { terminalId: destroyed.terminalId, status: destroyed.status });
714
+ }
715
+ catch {
716
+ writeError(response, runtimeError('capability_unavailable', 'terminal_stop_unconfirmed'));
717
+ }
714
718
  return;
715
719
  }
716
720
  writeJson(response, 404, { error: 'not_found' });
@@ -32,9 +32,16 @@ export class TerminalHost {
32
32
  disposed = false;
33
33
  sessions = new Map();
34
34
  spawning = new Map();
35
+ restored;
36
+ attempted = new Set();
37
+ closing = new Set();
38
+ stops = new Map();
39
+ confirmed = new Set();
35
40
  sockets = new WebSocketServer({ noServer: true });
36
41
  constructor(state) {
37
42
  this.state = state;
43
+ // An absent in-memory session cannot prove a restored terminal's old process group stopped.
44
+ this.restored = new Set((state.journal?.listTerminals?.() ?? []).map(terminal => terminal.terminalId));
38
45
  }
39
46
  async handleUpgrade(request, socket, head) {
40
47
  const url = new URL(request.url ?? '/', 'http://127.0.0.1');
@@ -68,6 +75,10 @@ export class TerminalHost {
68
75
  rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
69
76
  return;
70
77
  }
78
+ if (this.closing.has(terminalId) || this.restored.has(terminalId) && !this.sessions.has(terminalId)) {
79
+ rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_stop_unconfirmed'));
80
+ return;
81
+ }
71
82
  let session = this.sessions.get(terminal.terminalId);
72
83
  if (!session) {
73
84
  try {
@@ -84,14 +95,41 @@ export class TerminalHost {
84
95
  return;
85
96
  }
86
97
  }
87
- if (this.disposed || this.state.journal.destroyed() || this.state.journal.getTerminal(terminalId)?.status === 'destroyed') {
88
- this.revoke(terminalId, 'terminal_destroyed');
98
+ if (this.disposed || this.state.journal.destroyed() || this.closing.has(terminalId) || this.state.journal.getTerminal(terminalId)?.status === 'destroyed') {
99
+ if (!this.closing.has(terminalId))
100
+ this.revoke(terminalId, 'terminal_destroyed');
89
101
  rejectUpgrade(socket, 409, runtimeError('capability_unavailable', 'terminal_destroyed'));
90
102
  return;
91
103
  }
92
104
  const active = session;
93
105
  this.sockets.handleUpgrade(request, socket, head, (ws) => this.attach(active, ws));
94
106
  }
107
+ destroy(terminalId) {
108
+ const pending = this.stops.get(terminalId);
109
+ if (pending)
110
+ return pending;
111
+ this.closing.add(terminalId);
112
+ const stopped = (async () => {
113
+ await this.spawning.get(terminalId);
114
+ const session = this.sessions.get(terminalId);
115
+ if (session)
116
+ await session.stop();
117
+ else if (!this.confirmed.has(terminalId) && (this.restored.has(terminalId) || this.attempted.has(terminalId)))
118
+ throw new Error('terminal_stop_unconfirmed');
119
+ const terminal = this.state.journal.getTerminal(terminalId);
120
+ if (!terminal)
121
+ throw new Error('terminal_not_found');
122
+ const destroyed = terminal.status === 'destroyed' ? terminal : await this.state.journal.putTerminal({
123
+ ...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
124
+ });
125
+ this.confirmed.add(terminalId);
126
+ this.revoke(terminalId, 'terminal_destroyed');
127
+ this.closing.delete(terminalId);
128
+ return destroyed;
129
+ })().finally(() => this.stops.delete(terminalId));
130
+ this.stops.set(terminalId, stopped);
131
+ return stopped;
132
+ }
95
133
  revoke(terminalId, reason) {
96
134
  const session = this.sessions.get(terminalId);
97
135
  if (!session)
@@ -124,13 +162,15 @@ export class TerminalHost {
124
162
  const socketPath = process.env.PLAYCRAFT_LAUNCHER_SOCKET;
125
163
  if (!socketPath)
126
164
  throw new Error('pty_unavailable');
165
+ this.attempted.add(terminal.terminalId);
127
166
  const child = await startLauncherProcess({ socketPath, processId: `terminal-${randomUUID()}`,
128
167
  runtimeEpoch: this.state.runtimeEpoch, cwd, argv: [], env: {}, terminal: { cols: 80, rows: 24 } });
129
168
  if (this.disposed) {
130
169
  await child.stop();
131
170
  throw new Error('pty_unavailable');
132
171
  }
133
- const session = { streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
172
+ const session = { terminalId: terminal.terminalId, streamId: randomUUID(), log: createTerminalOutputLog(), listeners: new Set(),
173
+ stop: () => child.stop(),
134
174
  pty: { write: (data) => { child.stdin?.write(data); }, resize: child.resize,
135
175
  kill: () => { void child.stop().catch(() => undefined); } } };
136
176
  const decoder = new StringDecoder('utf8');
@@ -140,14 +180,20 @@ export class TerminalHost {
140
180
  const tail = decoder.end();
141
181
  if (tail)
142
182
  publish(session, tail);
143
- this.exited(session, code ?? null);
183
+ const stopped = code === undefined ? Promise.resolve(false) : child.stop().then(() => true, () => false);
184
+ void stopped.then(confirmed => this.exited(terminal.terminalId, session, confirmed ? code : undefined)).catch(() => undefined);
144
185
  });
145
186
  this.sessions.set(terminal.terminalId, session);
146
187
  return session;
147
188
  }
148
189
  const pty = require('node-pty');
149
190
  const command = commands.get(this.state) ?? defaultShell();
191
+ let finishExit;
192
+ let didExit = false;
193
+ const knownExit = new Promise(resolve => { finishExit = () => { didExit = true; resolve(); }; });
194
+ this.attempted.add(terminal.terminalId);
150
195
  const session = {
196
+ terminalId: terminal.terminalId,
151
197
  streamId: randomUUID(),
152
198
  log: createTerminalOutputLog(),
153
199
  listeners: new Set(),
@@ -158,17 +204,43 @@ export class TerminalHost {
158
204
  cwd,
159
205
  env: shellEnv(command.file, cwd),
160
206
  }),
207
+ stop: async () => {
208
+ if (!didExit)
209
+ session.pty.kill();
210
+ let timer;
211
+ try {
212
+ await Promise.race([knownExit, new Promise((_resolve, reject) => {
213
+ timer = setTimeout(() => reject(new Error('terminal_stop_unconfirmed')), 3000);
214
+ })]);
215
+ }
216
+ finally {
217
+ clearTimeout(timer);
218
+ }
219
+ },
161
220
  };
162
221
  const local = session.pty;
163
222
  local.onData((data) => publish(session, data));
164
- local.onExit(({ exitCode, signal }) => this.exited(session, exitCode, signal));
223
+ local.onExit(({ exitCode, signal }) => { finishExit(); void this.exited(terminal.terminalId, session, exitCode, signal).catch(() => undefined); });
165
224
  this.sessions.set(terminal.terminalId, session);
166
225
  return session;
167
226
  }
168
- exited(session, code, signal) {
169
- session.exited = { code, ...(signal === undefined ? {} : { signal: String(signal) }) };
170
- for (const listener of session.listeners)
171
- listener(exitFrame(session));
227
+ async exited(terminalId, session, code, signal) {
228
+ session.exited = { code: code ?? null, ...(signal === undefined ? {} : { signal: String(signal) }) };
229
+ try {
230
+ if (code !== undefined) {
231
+ const terminal = this.state.journal.getTerminal(terminalId);
232
+ if (terminal && terminal.status !== 'destroyed')
233
+ await this.state.journal.putTerminal({
234
+ ...terminal, status: 'destroyed', destroyedAt: new Date().toISOString(),
235
+ });
236
+ if (terminal)
237
+ this.confirmed.add(terminalId);
238
+ }
239
+ }
240
+ finally {
241
+ for (const listener of session.listeners)
242
+ listener(exitFrame(session));
243
+ }
172
244
  }
173
245
  attach(session, ws) {
174
246
  let open = true;
@@ -199,7 +271,7 @@ export class TerminalHost {
199
271
  return;
200
272
  }
201
273
  const parsed = terminalClientFrameSchema.safeParse(body);
202
- if (!parsed.success || session.exited && parsed.data.type !== 'resume')
274
+ if (!parsed.success || (session.exited || this.closing.has(session.terminalId)) && parsed.data.type !== 'resume')
203
275
  return;
204
276
  const frame = parsed.data;
205
277
  if (frame.type === 'resume') {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@playcraft/cli",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
@@ -50,8 +50,8 @@
50
50
  "@gltf-transform/core": "^4.3.0",
51
51
  "@gltf-transform/extensions": "^4.3.0",
52
52
  "@gltf-transform/functions": "^4.3.0",
53
- "@playcraft/build": "^0.1.27",
54
- "@playcraft/common": "^0.1.27",
53
+ "@playcraft/build": "^0.1.28",
54
+ "@playcraft/common": "^0.1.28",
55
55
  "better-sqlite3-pinned": "npm:better-sqlite3@13.0.3",
56
56
  "chokidar": "^4.0.3",
57
57
  "commander": "^13.1.0",