plotlink-ows 1.0.33 → 1.2.94

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 (145) hide show
  1. package/README.md +4 -0
  2. package/app/lib/agent-command.ts +85 -0
  3. package/app/lib/agent-readiness.ts +133 -0
  4. package/app/lib/apply-schema.ts +55 -0
  5. package/app/lib/bubble-text.ts +160 -0
  6. package/app/lib/cartoon-coach.ts +198 -0
  7. package/app/lib/cartoon-markdown.ts +83 -0
  8. package/app/lib/cartoon-prompt.ts +122 -0
  9. package/app/lib/cartoon-readiness.ts +811 -0
  10. package/app/lib/clean-image-sync.ts +245 -0
  11. package/app/lib/codex-images.ts +152 -0
  12. package/app/lib/cut-asset-diagnostics.ts +120 -0
  13. package/app/lib/cuts.ts +302 -0
  14. package/app/lib/fonts.ts +109 -0
  15. package/app/lib/generate-claude-md.ts +8 -1
  16. package/app/lib/generate-story-instructions.ts +731 -0
  17. package/app/lib/image-asset-validate.ts +123 -0
  18. package/app/lib/lettering-status.ts +133 -0
  19. package/app/lib/overlays.ts +637 -0
  20. package/app/lib/paths.ts +10 -0
  21. package/app/lib/public-title.ts +65 -0
  22. package/app/lib/publish.ts +16 -2
  23. package/app/lib/story-progress.ts +243 -0
  24. package/app/lib/terminal-protocol.ts +16 -0
  25. package/app/lib/terminal-redact.ts +50 -0
  26. package/app/prisma/schema.sql +25 -0
  27. package/app/routes/agent.ts +42 -0
  28. package/app/routes/codex-images.ts +67 -0
  29. package/app/routes/publish.ts +203 -22
  30. package/app/routes/stories.ts +961 -5
  31. package/app/routes/terminal.ts +383 -31
  32. package/app/server.ts +47 -12
  33. package/app/vite.config.ts +6 -0
  34. package/app/web/components/CartoonPreview.tsx +267 -0
  35. package/app/web/components/CartoonPublishPage.tsx +407 -0
  36. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  37. package/app/web/components/CartoonStepGuide.tsx +90 -0
  38. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  39. package/app/web/components/CodexImportPicker.tsx +230 -0
  40. package/app/web/components/CutListPanel.tsx +1299 -0
  41. package/app/web/components/EpisodesPage.tsx +80 -0
  42. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  43. package/app/web/components/Layout.tsx +7 -4
  44. package/app/web/components/LetteringEditor.tsx +1141 -0
  45. package/app/web/components/PreviewPanel.tsx +951 -78
  46. package/app/web/components/Settings.tsx +63 -0
  47. package/app/web/components/StoriesPage.tsx +710 -33
  48. package/app/web/components/StoryBrowser.tsx +22 -14
  49. package/app/web/components/StoryInfoPage.tsx +266 -0
  50. package/app/web/components/StoryProgressPanel.tsx +516 -0
  51. package/app/web/components/TerminalPanel.tsx +233 -11
  52. package/app/web/components/WorkflowCoach.tsx +128 -0
  53. package/app/web/components/asset-image.tsx +114 -0
  54. package/app/web/components/asset-test-utils.ts +44 -0
  55. package/app/web/components/export-cut.ts +320 -0
  56. package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
  57. package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
  58. package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
  59. package/app/web/dist/index.html +2 -2
  60. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  61. package/app/web/lib/codex-import.ts +94 -0
  62. package/app/web/lib/image-compress.ts +53 -0
  63. package/app/web/lib/import-image.ts +58 -0
  64. package/app/web/lib/publish-helpers.ts +385 -0
  65. package/app/web/lib/upload-retry.ts +130 -0
  66. package/app/web/lib/verify-public-title.ts +105 -0
  67. package/app/web/styles.css +9 -0
  68. package/bin/plotlink-ows.js +53 -16
  69. package/bin/startup-plan.cjs +58 -0
  70. package/lib/genres.ts +92 -0
  71. package/package.json +60 -20
  72. package/scripts/gen-schema-sql.mjs +49 -0
  73. package/scripts/package-hygiene.mjs +116 -0
  74. package/scripts/preflight.mjs +173 -0
  75. package/scripts/start-smoke.mjs +128 -0
  76. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  77. package/app/node_modules/.prisma/local-client/client.js +0 -5
  78. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  79. package/app/node_modules/.prisma/local-client/default.js +0 -5
  80. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  81. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  82. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  83. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  84. package/app/node_modules/.prisma/local-client/index.js +0 -207
  85. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  86. package/app/node_modules/.prisma/local-client/package.json +0 -183
  87. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  88. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  89. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  90. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  91. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  92. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  93. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  94. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  95. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  96. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  97. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  98. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  99. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  100. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  101. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  102. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  103. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  104. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  105. package/packages/cli/node_modules/commander/LICENSE +0 -22
  106. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  107. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  108. package/packages/cli/node_modules/commander/index.js +0 -24
  109. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  110. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  111. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  112. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  113. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  114. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  115. package/packages/cli/node_modules/commander/package-support.json +0 -16
  116. package/packages/cli/node_modules/commander/package.json +0 -82
  117. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  118. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  119. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  120. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  121. package/packages/cli/node_modules/resolve-from/license +0 -9
  122. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  123. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  124. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  125. package/packages/cli/node_modules/tsup/README.md +0 -75
  126. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  127. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  128. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  129. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  130. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  131. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  132. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  133. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  134. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  135. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  136. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  137. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  138. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  139. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  140. package/packages/cli/node_modules/tsup/package.json +0 -99
  141. package/packages/cli/node_modules/tsup/schema.json +0 -362
  142. package/public/screenshot-1.png +0 -0
  143. package/public/screenshot-2.png +0 -0
  144. package/public/screenshot-3.png +0 -0
  145. package/scripts/e2e-verify.ts +0 -1100
@@ -4,9 +4,89 @@ import path from "path";
4
4
  import fs from "fs";
5
5
  import { randomUUID } from "crypto";
6
6
  import { STORIES_DIR, DATA_DIR } from "../lib/paths";
7
+ import { readStoryMeta, writeStoryMeta } from "./stories";
8
+ import type { AgentProvider } from "./stories";
9
+ import { writeStoryInstructions } from "../lib/generate-story-instructions";
10
+ import { buildAgentCommand } from "../lib/agent-command";
11
+ import type { AgentMode, AgentCommand } from "../lib/agent-command";
12
+ import { FRESH_SPAWN_SIGNAL } from "../lib/terminal-protocol";
13
+
14
+ /**
15
+ * Provider-aware session record (new shape). Written ONLY for Codex sessions.
16
+ * Claude sessions keep being persisted as a bare string (legacy shape) so a
17
+ * rollback to an older app version still resumes fiction/Claude stories.
18
+ */
19
+ export interface SessionRecord {
20
+ provider: AgentProvider;
21
+ sessionId: string | null;
22
+ lastStartedAt?: number;
23
+ }
24
+ export type StoredValue = string | SessionRecord;
25
+
26
+ export function isSessionRecord(v: StoredValue | undefined): v is SessionRecord {
27
+ return typeof v === "object" && v !== null && "provider" in v;
28
+ }
29
+
30
+ /** Resolve a resume id from either stored shape (string → itself, record → .sessionId). */
31
+ export function resumeIdFrom(v: StoredValue | undefined): string | null {
32
+ if (typeof v === "string") return v;
33
+ if (isSessionRecord(v)) return typeof v.sessionId === "string" ? v.sessionId : null;
34
+ return null;
35
+ }
36
+
37
+ /**
38
+ * Resolve the concrete agent CLI invocation (argv) for a Codex spawn, given the
39
+ * stored session value and whether the user requested a resume.
40
+ *
41
+ * Codex decouples "resume requested" from "stored id exists" — unlike Claude:
42
+ * - Claude needs a CONCRETE session id to resume (`--resume <id>`); with no
43
+ * stored id it must start fresh (`--session-id <new>`). So Claude resume only
44
+ * happens when both resumeRequested AND a stored id exist.
45
+ * - Codex can resume the most recent session with no id at all
46
+ * (`codex resume --last`). So a resume request alone is enough; a stored id
47
+ * (when present) just picks a specific session (`codex resume <id>`).
48
+ *
49
+ * This is the single code path shared by spawnPty (codex branch) and the
50
+ * route/session regression tests, so they exercise identical logic.
51
+ */
52
+ export function resolveAgentCommandForSession(opts: {
53
+ provider: AgentProvider;
54
+ mode: AgentMode;
55
+ resumeRequested: boolean;
56
+ stored: StoredValue | undefined;
57
+ newSessionId: string;
58
+ storyDir: string;
59
+ }): AgentCommand {
60
+ const { provider, mode, resumeRequested, stored, newSessionId, storyDir } = opts;
61
+ const storedResumeId = resumeIdFrom(stored);
62
+
63
+ if (provider === "claude") {
64
+ // Claude requires a concrete id to resume; otherwise fall back to fresh.
65
+ const doResume = !!(resumeRequested && storedResumeId);
66
+ return buildAgentCommand({
67
+ provider,
68
+ mode,
69
+ resume: doResume,
70
+ sessionId: doResume ? storedResumeId : null,
71
+ newSessionId,
72
+ storyDir,
73
+ });
74
+ }
75
+
76
+ // Codex: a resume request alone is enough even with no stored id (resume --last).
77
+ return buildAgentCommand({
78
+ provider,
79
+ mode,
80
+ resume: resumeRequested,
81
+ sessionId: storedResumeId,
82
+ newSessionId,
83
+ storyDir,
84
+ });
85
+ }
7
86
 
8
87
  const MAX_SESSIONS = 5;
9
88
  const SESSION_FILE = path.join(DATA_DIR, "terminal-sessions.json");
89
+ const WS_OPEN = 1;
10
90
 
11
91
  const terminal = new Hono();
12
92
 
@@ -23,8 +103,12 @@ function safeName(name: string): string | null {
23
103
  return name;
24
104
  }
25
105
 
26
- /** Load stored session UUIDs from disk */
27
- function loadSessionMap(): Record<string, string> {
106
+ /**
107
+ * Load stored sessions from disk. Values may be legacy bare strings (Claude
108
+ * UUIDs) OR new provider-aware records. The file is NEVER migrated wholesale —
109
+ * only the touched key changes shape when actively updated.
110
+ */
111
+ function loadSessionMap(): Record<string, StoredValue> {
28
112
  try {
29
113
  if (fs.existsSync(SESSION_FILE)) {
30
114
  return JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
@@ -33,8 +117,8 @@ function loadSessionMap(): Record<string, string> {
33
117
  return {};
34
118
  }
35
119
 
36
- /** Save session UUIDs to disk */
37
- function saveSessionMap(map: Record<string, string>) {
120
+ /** Save sessions to disk (mixed legacy-string / record values). */
121
+ function saveSessionMap(map: Record<string, StoredValue>) {
38
122
  try {
39
123
  const dir = path.dirname(SESSION_FILE);
40
124
  if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
@@ -42,31 +126,228 @@ function saveSessionMap(map: Record<string, string>) {
42
126
  } catch { /* ignore */ }
43
127
  }
44
128
 
45
- function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean }) {
129
+ /**
130
+ * Build the Claude CLI command string for a session.
131
+ * - resume: reuse an existing session ID
132
+ * - bypass: add --dangerously-skip-permissions (opt-in, less safe)
133
+ */
134
+ export function buildClaudeCommand(opts: {
135
+ resume: boolean;
136
+ sessionId: string;
137
+ bypass?: boolean;
138
+ }): string {
139
+ let cmd = "claude";
140
+ if (opts.resume) {
141
+ cmd += ` --resume "${opts.sessionId}"`;
142
+ } else {
143
+ cmd += ` --session-id "${opts.sessionId}"`;
144
+ }
145
+ if (opts.bypass) {
146
+ cmd += " --dangerously-skip-permissions";
147
+ }
148
+ return cmd;
149
+ }
150
+
151
+ export function isTerminalSocketOpen(ws: Pick<WebSocket, "readyState">): boolean {
152
+ return ws.readyState === WS_OPEN;
153
+ }
154
+
155
+ /**
156
+ * POSIX single-quote escape for embedding an arbitrary value in a shell string.
157
+ *
158
+ * We invoke the agent via a login shell (`pty.spawn(shell, ["-l","-c", cmd])`)
159
+ * so the user's PATH resolves the `claude`/`codex` binary. That means the argv
160
+ * is assembled into a single shell-parsed string, so every token must be quoted
161
+ * safely. Single-quoting (with the `'\''` trick for embedded quotes) is the only
162
+ * shell quoting that disables ALL special characters ($, `, ", \, spaces), so a
163
+ * value containing `"`, `$`, or a backtick cannot break out of its token.
164
+ */
165
+ export function shellQuote(s: string): string {
166
+ return "'" + s.replace(/'/g, "'\\''") + "'";
167
+ }
168
+
169
+ // In-memory agent mode per active session name (covers _new_ sessions and
170
+ // reconnects before a story directory / .story.json exists).
171
+ const agentModeBySession = new Map<string, "normal" | "bypass">();
172
+
173
+ // In-memory agent provider per active session name (covers _new_ sessions and
174
+ // reconnects before a story directory / .story.json exists). Mirrors
175
+ // agentModeBySession exactly.
176
+ const agentProviderBySession = new Map<string, "claude" | "codex">();
177
+
178
+ /**
179
+ * Resolve effective permissions-bypass for a spawn.
180
+ *
181
+ * The client-supplied bypass flag is only trusted for a brand-new (_new_)
182
+ * story's first spawn. For existing stories, bypass derives strictly from
183
+ * server-side state (already-spawned session mode, then stored .story.json),
184
+ * so a direct WS URL cannot force bypass on a story whose metadata says normal.
185
+ */
186
+ export function resolveBypass(args: {
187
+ isNewStory: boolean;
188
+ optBypass?: boolean;
189
+ sessionMode?: "normal" | "bypass";
190
+ storedMode?: "normal" | "bypass";
191
+ }): boolean {
192
+ if (args.isNewStory) {
193
+ return args.optBypass ?? args.sessionMode === "bypass";
194
+ }
195
+ if (args.sessionMode !== undefined) {
196
+ return args.sessionMode === "bypass";
197
+ }
198
+ return args.storedMode === "bypass";
199
+ }
200
+
201
+ /**
202
+ * Resolve the effective agent provider for a spawn.
203
+ *
204
+ * Mirrors resolveBypass's trust model: the client-supplied provider flag is only
205
+ * trusted for a brand-new (_new_) story's first spawn. For existing stories the
206
+ * provider derives strictly from server-side state (already-spawned session
207
+ * provider, then stored .story.json), so a direct WS URL cannot force a provider
208
+ * on a story whose metadata says otherwise.
209
+ */
210
+ export function resolveProvider(args: {
211
+ isNewStory: boolean;
212
+ optProvider?: "claude" | "codex";
213
+ sessionProvider?: "claude" | "codex";
214
+ storedProvider?: "claude" | "codex";
215
+ }): "claude" | "codex" {
216
+ if (args.isNewStory) return args.optProvider ?? args.sessionProvider ?? "claude";
217
+ if (args.sessionProvider !== undefined) return args.sessionProvider;
218
+ return args.storedProvider ?? "claude";
219
+ }
220
+
221
+ type StoryMetaShape = ReturnType<typeof readStoryMeta>;
222
+
223
+ /**
224
+ * Decide the `.story.json` metadata to persist when a `_new_*` session is
225
+ * confirmed/renamed into its real story folder (#295).
226
+ *
227
+ * The fresh-cartoon repair-banner bug happened because `agentProvider` was only
228
+ * persisted by a fire-and-forget client POST after the rename — so if that POST
229
+ * was dropped (or the page reloaded), a cartoon story had `contentType:"cartoon"`
230
+ * but no recorded provider and falsely looked "legacy". Persisting here makes the
231
+ * rename the authoritative confirm step.
232
+ *
233
+ * Pure/deterministic: merges the new story's intended fields over whatever the
234
+ * folder already has. Provider falls back to the carried session provider when
235
+ * the request body omits it (the server already tracks it per session). Returns
236
+ * null when there is nothing new to record (no provider and no explicit
237
+ * contentType) so an unrelated rename never rewrites `.story.json`.
238
+ */
239
+ export function resolveRenamedStoryMeta(args: {
240
+ existing: StoryMetaShape;
241
+ bodyContentType?: string;
242
+ bodyLanguage?: string;
243
+ bodyAgentMode?: string;
244
+ bodyProvider?: string;
245
+ sessionProvider?: AgentProvider;
246
+ }): StoryMetaShape | null {
247
+ const provider: AgentProvider | undefined =
248
+ args.bodyProvider === "claude" || args.bodyProvider === "codex" ? args.bodyProvider : args.sessionProvider;
249
+ const hasExplicitContentType = args.bodyContentType === "cartoon" || args.bodyContentType === "fiction";
250
+ if (!provider && !hasExplicitContentType) return null;
251
+
252
+ const contentType = hasExplicitContentType
253
+ ? (args.bodyContentType as "cartoon" | "fiction")
254
+ : args.existing.contentType;
255
+
256
+ return {
257
+ ...args.existing,
258
+ contentType,
259
+ ...(typeof args.bodyLanguage === "string" && args.bodyLanguage ? { language: args.bodyLanguage } : {}),
260
+ ...(args.bodyAgentMode === "bypass" || args.bodyAgentMode === "normal" ? { agentMode: args.bodyAgentMode } : {}),
261
+ ...(provider ? { agentProvider: provider } : {}),
262
+ };
263
+ }
264
+
265
+ /**
266
+ * Move a persisted session entry from one key to another, PRESERVING its stored
267
+ * shape. A `_new_*` Codex session is stored as a provider-aware record
268
+ * (`{provider:"codex", sessionId, lastStartedAt}`); a Claude session as a bare
269
+ * string. Renaming must keep that shape so Codex resume metadata survives. Only
270
+ * when there is no stored entry do we fall back to the live PTY session id (a
271
+ * bare string, matching legacy Claude behavior). Mutates and returns `map`.
272
+ */
273
+ export function carrySessionAcrossRename(
274
+ map: Record<string, StoredValue>,
275
+ oldName: string,
276
+ newName: string,
277
+ fallbackSessionId: string,
278
+ ): Record<string, StoredValue> {
279
+ const existing = map[oldName];
280
+ delete map[oldName];
281
+ map[newName] = existing ?? fallbackSessionId;
282
+ return map;
283
+ }
284
+
285
+ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean; bypass?: boolean; provider?: "claude" | "codex" }) {
46
286
  // New story sessions spawn in the stories root so Claude can create any folder
47
287
  const isNewStory = storyName.startsWith("_new_");
48
288
  const storyDir = isNewStory ? STORIES_DIR : path.join(STORIES_DIR, storyName);
49
289
  if (!fs.existsSync(storyDir)) fs.mkdirSync(storyDir, { recursive: true });
50
290
  const shell = process.env.SHELL || "/bin/zsh";
51
291
 
52
- // Determine session ID
292
+ // Resolve effective agent mode (see resolveBypass for the trust model).
293
+ const bypass = resolveBypass({
294
+ isNewStory,
295
+ optBypass: opts?.bypass,
296
+ sessionMode: agentModeBySession.get(storyName),
297
+ storedMode: isNewStory ? undefined : readStoryMeta(storyDir).agentMode,
298
+ });
299
+ agentModeBySession.set(storyName, bypass ? "bypass" : "normal");
300
+
301
+ // Resolve effective provider (see resolveProvider for the trust model). For a
302
+ // brand-new _new_ session the client flag is trusted; existing stories ignore
303
+ // it and read from session state then stored .story.json (no migration).
304
+ const provider: AgentProvider = resolveProvider({
305
+ isNewStory,
306
+ optProvider: opts?.provider,
307
+ sessionProvider: agentProviderBySession.get(storyName),
308
+ storedProvider: isNewStory ? undefined : readStoryMeta(storyDir).agentProvider,
309
+ });
310
+ agentProviderBySession.set(storyName, provider);
311
+
312
+ // Write provider-aware CLAUDE.md AFTER provider resolution so a Codex cartoon
313
+ // session gets the create-the-file contract and a Claude/legacy session gets the
314
+ // manual prompt-and-import handoff (#274). New (_new_) stories have no named
315
+ // folder yet — their CLAUDE.md is written when the story is created/named.
316
+ if (!isNewStory) {
317
+ writeStoryInstructions(storyDir, readStoryMeta(storyDir).contentType, provider);
318
+ }
319
+
320
+ // Determine resume id (accepts both legacy-string and record shapes).
53
321
  const sessionMap = loadSessionMap();
54
- let sessionId: string;
55
-
56
- // Build Claude CLI command with session flags
57
- // Note: no --cwd flag — Claude CLI uses process cwd, set via pty.spawn({ cwd: storyDir })
58
- let claudeCmd = "claude";
59
- if (opts?.resume && sessionMap[storyName]) {
60
- // Resume: reuse stored session
61
- sessionId = sessionMap[storyName];
62
- claudeCmd += ` --resume "${sessionId}"`;
322
+ const stored = sessionMap[storyName];
323
+ const storedResumeId = resumeIdFrom(stored);
324
+ // Claude needs a concrete stored id to resume; with none it starts fresh.
325
+ const doResume = !!(opts?.resume && storedResumeId);
326
+ // Fresh Claude reuses any explicit opts.sessionId (back-compat) else a new UUID.
327
+ const sessionId = doResume ? (storedResumeId as string) : (opts?.sessionId || randomUUID());
328
+
329
+ let agentCmd: string;
330
+ if (provider === "claude") {
331
+ // KEEP BYTE-IDENTICAL: same buildClaudeCommand output as before.
332
+ agentCmd = buildClaudeCommand({ resume: doResume, sessionId, bypass });
63
333
  } else {
64
- // Fresh: always generate new UUID
65
- sessionId = opts?.sessionId || randomUUID();
66
- claudeCmd += ` --session-id "${sessionId}"`;
334
+ // Codex decouples "resume requested" from "stored id exists": a resume
335
+ // request alone yields `codex resume --last` (no stored id needed), while a
336
+ // stored id picks a specific session (`codex resume <id>`). Render argv via
337
+ // the shared resolver, then quote it into an injection-safe shell string.
338
+ const { command, args } = resolveAgentCommandForSession({
339
+ provider,
340
+ mode: bypass ? "bypass" : "normal",
341
+ resumeRequested: !!opts?.resume,
342
+ stored,
343
+ newSessionId: sessionId,
344
+ storyDir,
345
+ });
346
+ agentCmd = [command, ...args].map(shellQuote).join(" ");
67
347
  }
68
348
 
69
- const term = pty.spawn(shell, ["-l", "-c", claudeCmd], {
349
+ // No --cwd flag for Claude — it uses process cwd, set via pty.spawn({ cwd }).
350
+ const term = pty.spawn(shell, ["-l", "-c", agentCmd], {
70
351
  name: "xterm-256color",
71
352
  cols: 120,
72
353
  rows: 30,
@@ -74,8 +355,17 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole
74
355
  env: process.env as Record<string, string>,
75
356
  });
76
357
 
77
- // Persist session ID
78
- sessionMap[storyName] = sessionId;
358
+ // Persist session info. Claude keeps the legacy bare-string shape (rollback
359
+ // safe); Codex writes a provider-aware record (its own id resolves later).
360
+ if (provider === "claude") {
361
+ sessionMap[storyName] = sessionId;
362
+ } else {
363
+ sessionMap[storyName] = {
364
+ provider,
365
+ sessionId: doResume ? sessionId : null,
366
+ lastStartedAt: Date.now(),
367
+ };
368
+ }
79
369
  saveSessionMap(sessionMap);
80
370
 
81
371
  const isResume = !!opts?.resume;
@@ -117,9 +407,10 @@ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boole
117
407
 
118
408
  /** POST /api/terminal/spawn — spawn Claude CLI for a story */
119
409
  terminal.post("/spawn", async (c) => {
120
- const body = await c.req.json<{ storyName?: string; resume?: boolean }>().catch(() => ({}));
410
+ const body = await c.req.json<{ storyName?: string; resume?: boolean; provider?: "claude" | "codex" }>().catch(() => ({}));
121
411
  const storyName = safeName(body.storyName || "default");
122
412
  if (!storyName) return c.json({ error: "Invalid story name" }, 400);
413
+ const optProvider = body.provider === "claude" || body.provider === "codex" ? body.provider : undefined;
123
414
 
124
415
  const existing = ptySessions.get(storyName);
125
416
  if (existing?.term && existing.state === "running") {
@@ -133,7 +424,7 @@ terminal.post("/spawn", async (c) => {
133
424
  }
134
425
 
135
426
  try {
136
- const session = spawnPty(storyName, { resume: body.resume });
427
+ const session = spawnPty(storyName, { resume: body.resume, provider: optProvider });
137
428
  return c.json({ ok: true, pid: session.term.pid, storyName, sessionId: session.sessionId });
138
429
  } catch (err: unknown) {
139
430
  const message = err instanceof Error ? err.message : "Failed to spawn PTY";
@@ -147,7 +438,7 @@ terminal.get("/session/:storyName", (c) => {
147
438
  if (!storyName) return c.json({ error: "Invalid story name" }, 400);
148
439
 
149
440
  const sessionMap = loadSessionMap();
150
- const sessionId = sessionMap[storyName] || null;
441
+ const sessionId = resumeIdFrom(sessionMap[storyName]);
151
442
  const active = ptySessions.get(storyName);
152
443
 
153
444
  return c.json({
@@ -200,7 +491,16 @@ terminal.delete("/:storyName/discard", (c) => {
200
491
 
201
492
  /** POST /api/terminal/rename — rename a session key without killing the process */
202
493
  terminal.post("/rename", async (c) => {
203
- const body = await c.req.json<{ oldName?: string; newName?: string }>().catch(() => ({}));
494
+ const body = await c.req.json<{
495
+ oldName?: string;
496
+ newName?: string;
497
+ // Optional metadata for the confirmed story, persisted atomically with the
498
+ // rename so a fresh story's contentType/provider/mode survive (#295).
499
+ contentType?: string;
500
+ language?: string;
501
+ agentMode?: string;
502
+ agentProvider?: string;
503
+ }>().catch(() => ({}));
204
504
  const oldName = body.oldName && safeName(body.oldName);
205
505
  const newName = body.newName && safeName(body.newName);
206
506
  if (!oldName || !newName) return c.json({ error: "Invalid names" }, 400);
@@ -215,12 +515,52 @@ terminal.post("/rename", async (c) => {
215
515
  ptySessions.delete(oldName);
216
516
  ptySessions.set(newName, session);
217
517
 
218
- // Update persisted session map: remove old key, store under new key
518
+ // Carry the in-memory agent mode across the rename so reconnects stay consistent
519
+ const oldMode = agentModeBySession.get(oldName);
520
+ if (oldMode) {
521
+ agentModeBySession.set(newName, oldMode);
522
+ agentModeBySession.delete(oldName);
523
+ }
524
+
525
+ // Carry the in-memory agent provider across the rename too (mirrors mode).
526
+ const oldProvider = agentProviderBySession.get(oldName);
527
+ if (oldProvider) {
528
+ agentProviderBySession.set(newName, oldProvider);
529
+ agentProviderBySession.delete(oldName);
530
+ }
531
+
532
+ // Update persisted session map: carry the stored value across the rename so a
533
+ // provider-aware Codex record (`{provider,sessionId,...}`) or a legacy Claude
534
+ // string is PRESERVED, not flattened to the live PTY's fallback UUID. Writing
535
+ // session.sessionId here corrupted Codex metadata (the fresh-spawn fallback
536
+ // UUID is not a real Codex session id, so later resume built `codex resume
537
+ // <uuid>` instead of `codex resume --last`).
219
538
  const sessionMap = loadSessionMap();
220
- delete sessionMap[oldName];
221
- sessionMap[newName] = session.sessionId;
539
+ carrySessionAcrossRename(sessionMap, oldName, newName, session.sessionId);
222
540
  saveSessionMap(sessionMap);
223
541
 
542
+ // Persist the confirmed story's metadata atomically with the rename so a fresh
543
+ // story's agentProvider/contentType survive the _new_* → real-folder transition
544
+ // even if the client's follow-up metadata POST is dropped or the page reloads
545
+ // (#295). Provider falls back to the carried session value the server already
546
+ // tracks, so a cartoon's Codex provider is recorded without trusting the body.
547
+ const storyDir = path.join(STORIES_DIR, newName);
548
+ if (fs.existsSync(storyDir) && fs.statSync(storyDir).isDirectory()) {
549
+ const meta = resolveRenamedStoryMeta({
550
+ existing: readStoryMeta(storyDir),
551
+ bodyContentType: body.contentType,
552
+ bodyLanguage: body.language,
553
+ bodyAgentMode: body.agentMode,
554
+ bodyProvider: body.agentProvider,
555
+ sessionProvider: agentProviderBySession.get(newName),
556
+ });
557
+ if (meta) {
558
+ writeStoryMeta(storyDir, meta);
559
+ // Keep CLAUDE.md provider-aware in step with the recorded provider.
560
+ writeStoryInstructions(storyDir, meta.contentType, meta.agentProvider);
561
+ }
562
+ }
563
+
224
564
  return c.json({ ok: true, sessionId: session.sessionId });
225
565
  });
226
566
 
@@ -252,10 +592,15 @@ terminal.get("/status", (c) => {
252
592
  * Attach a raw WebSocket to a story's PTY session.
253
593
  * Called from server.ts WebSocket upgrade handler.
254
594
  */
255
- export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean) {
595
+ export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean, bypass?: boolean, provider?: "claude" | "codex") {
256
596
  const name = storyName && safeName(storyName) ? storyName : "default";
257
597
  let session = ptySessions.get(name);
258
598
 
599
+ // Whether this connection SPAWNS a fresh process vs. reconnecting to a live
600
+ // PTY. A fresh (re)spawn reprints its own banner/history, so the client must
601
+ // drop any restored scrollback to avoid a duplicated startup banner (#453).
602
+ const spawnedFresh = !session || session.state !== "running";
603
+
259
604
  // Lazy spawn if no PTY exists
260
605
  if (!session || session.state !== "running") {
261
606
  // Enforce max concurrent sessions
@@ -266,7 +611,7 @@ export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boo
266
611
  }
267
612
 
268
613
  try {
269
- session = spawnPty(name, { resume });
614
+ session = spawnPty(name, { resume, bypass, provider });
270
615
  } catch (err) {
271
616
  console.error("PTY spawn failed:", err);
272
617
  ws.close(1011, "pty-spawn-failed");
@@ -280,9 +625,16 @@ export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boo
280
625
  }
281
626
  session.ws = ws;
282
627
 
628
+ // Signal a fresh spawn as the FIRST frame, before any PTY output, so the
629
+ // client drops its restored scrollback and shows only the fresh reprint (#453).
630
+ // A live-PTY reconnect sends nothing, so the client keeps its scrollback.
631
+ if (spawnedFresh && isTerminalSocketOpen(ws)) {
632
+ ws.send(FRESH_SPAWN_SIGNAL);
633
+ }
634
+
283
635
  // PTY output → browser
284
636
  const dataDisposable = session.term.onData((data: string) => {
285
- if (ws.readyState === WebSocket.OPEN) {
637
+ if (isTerminalSocketOpen(ws)) {
286
638
  ws.send(data);
287
639
  }
288
640
  });
package/app/server.ts CHANGED
@@ -22,9 +22,11 @@ import { dashboardRoutes } from "./routes/dashboard";
22
22
  import { terminalRoutes, attachTerminalWs } from "./routes/terminal";
23
23
  import { storiesRoutes } from "./routes/stories";
24
24
  import { settingsRoutes } from "./routes/settings";
25
- import { initDb } from "./db";
25
+ import { agentRoutes } from "./routes/agent";
26
+ import { codexImagesRoutes } from "./routes/codex-images";
27
+ import { db, initDb } from "./db";
26
28
  import { generateClaudeMd } from "./lib/generate-claude-md";
27
- import { execSync } from "child_process";
29
+ import { loadSchemaStatements } from "./lib/apply-schema";
28
30
  import fs from "fs";
29
31
 
30
32
  const __dirname = __dirnamePre;
@@ -48,6 +50,10 @@ app.use("/api/stories/*", requireAuth);
48
50
  app.route("/api/stories", storiesRoutes);
49
51
  app.use("/api/settings/*", requireAuth);
50
52
  app.route("/api/settings", settingsRoutes);
53
+ app.use("/api/agent/*", requireAuth);
54
+ app.route("/api/agent", agentRoutes);
55
+ app.use("/api/codex/*", requireAuth);
56
+ app.route("/api/codex", codexImagesRoutes);
51
57
 
52
58
  // App version (read once at startup)
53
59
  const appVersion = (() => {
@@ -128,15 +134,36 @@ async function start() {
128
134
  // Generate/update ~/.plotlink-ows/CLAUDE.md for agent discovery
129
135
  generateClaudeMd();
130
136
 
131
- // Run Prisma db push to ensure schema is up to date
132
- const schemaPath = path.join(__dirname, "prisma", "schema.prisma");
133
- execSync(`npx prisma db push --schema ${schemaPath} --skip-generate`, {
134
- stdio: "inherit",
135
- env: { ...process.env, DATABASE_URL },
136
- });
137
-
138
- // Initialize database connection
139
- await initDb();
137
+ // Bring the local SQLite schema up to date WITHOUT the native Prisma
138
+ // schema-engine. `prisma db push` spawns a platform-specific schema-engine
139
+ // binary that fails to start in some packed prod-only installs (#484, EPIC
140
+ // #465: an empty "Schema engine error:" on macOS arm64). Instead we apply the
141
+ // committed DDL (app/prisma/schema.sql, generated from schema.prisma) through
142
+ // the Prisma client's library query engine — the same engine the app already
143
+ // uses for every query, so if the app runs at all, schema setup runs too.
144
+ // SQLite creates the DB file but NOT its parent dir, so ensure
145
+ // ~/.plotlink-ows/data exists first (a fresh prod-only install has none).
146
+ fs.mkdirSync(DATA_DIR, { recursive: true });
147
+ const schemaSqlPath = path.join(__dirname, "prisma", "schema.sql");
148
+ try {
149
+ await initDb(); // connect the client (library query engine; no schema-engine)
150
+ // Statements are CREATE TABLE/INDEX IF NOT EXISTS, so this is idempotent and
151
+ // safe to run on every startup against an existing database.
152
+ for (const statement of loadSchemaStatements(schemaSqlPath)) {
153
+ await db.$executeRawUnsafe(statement);
154
+ }
155
+ } catch (err) {
156
+ // Surface a useful diagnostic instead of a raw stack (#479/#484).
157
+ const home = os.homedir();
158
+ const redact = (s: string) => s.split(home).join("~");
159
+ console.error("\n ✗ Database setup failed (applying schema.sql).");
160
+ console.error(` schema: ${redact(schemaSqlPath)}`);
161
+ console.error(` database: ${redact(DATABASE_URL)}`);
162
+ console.error(` reason: ${err instanceof Error ? err.message : String(err)}`);
163
+ console.error(" This usually means a corrupted install (missing the generated Prisma");
164
+ console.error(" client/query engine or schema.sql). Reinstall with: npx plotlink-ows@latest\n");
165
+ process.exit(1);
166
+ }
140
167
 
141
168
  const port = Number(process.env.APP_PORT) || 7777;
142
169
  const server = serve({ fetch: app.fetch, port }, (info) => {
@@ -159,8 +186,16 @@ async function start() {
159
186
  if (!session || session.expiresAt < new Date()) { socket.destroy(); return; }
160
187
  const story = url.searchParams.get("story") || undefined;
161
188
  const resume = url.searchParams.get("resume") === "true";
189
+ const bypass = url.searchParams.get("bypass") === "true";
190
+ const provider = url.searchParams.get("provider");
162
191
  wss.handleUpgrade(req, socket, head, (ws) => {
163
- attachTerminalWs(ws as unknown as WebSocket, story, resume);
192
+ attachTerminalWs(
193
+ ws as unknown as WebSocket,
194
+ story,
195
+ resume,
196
+ bypass,
197
+ provider === "claude" || provider === "codex" ? provider : undefined,
198
+ );
164
199
  });
165
200
  }).catch(() => socket.destroy());
166
201
  }
@@ -1,10 +1,16 @@
1
1
  import { defineConfig } from "vite";
2
2
  import react from "@vitejs/plugin-react";
3
3
  import tailwindcss from "@tailwindcss/vite";
4
+ import path from "path";
4
5
 
5
6
  export default defineConfig({
6
7
  root: "app/web",
7
8
  plugins: [react(), tailwindcss()],
9
+ resolve: {
10
+ alias: {
11
+ "@app-lib": path.resolve(__dirname, "lib"),
12
+ },
13
+ },
8
14
  server: {
9
15
  port: 5173,
10
16
  proxy: {