plotlink-ows 1.0.33 → 1.2.95

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 (152) hide show
  1. package/README.md +4 -0
  2. package/app/lib/active-wallet.ts +260 -0
  3. package/app/lib/agent-command.ts +85 -0
  4. package/app/lib/agent-readiness.ts +133 -0
  5. package/app/lib/apply-schema.ts +55 -0
  6. package/app/lib/bubble-text.ts +160 -0
  7. package/app/lib/cartoon-coach.ts +198 -0
  8. package/app/lib/cartoon-markdown.ts +83 -0
  9. package/app/lib/cartoon-prompt.ts +122 -0
  10. package/app/lib/cartoon-readiness.ts +813 -0
  11. package/app/lib/clean-image-sync.ts +245 -0
  12. package/app/lib/codex-images.ts +152 -0
  13. package/app/lib/cut-asset-diagnostics.ts +120 -0
  14. package/app/lib/cuts.ts +302 -0
  15. package/app/lib/fonts.ts +109 -0
  16. package/app/lib/generate-claude-md.ts +8 -1
  17. package/app/lib/generate-story-instructions.ts +731 -0
  18. package/app/lib/image-asset-validate.ts +123 -0
  19. package/app/lib/lettering-status.ts +133 -0
  20. package/app/lib/overlays.ts +637 -0
  21. package/app/lib/paths.ts +10 -0
  22. package/app/lib/public-title.ts +65 -0
  23. package/app/lib/publish.ts +16 -2
  24. package/app/lib/story-progress.ts +242 -0
  25. package/app/lib/terminal-protocol.ts +16 -0
  26. package/app/lib/terminal-redact.ts +50 -0
  27. package/app/prisma/schema.sql +25 -0
  28. package/app/routes/agent.ts +42 -0
  29. package/app/routes/codex-images.ts +67 -0
  30. package/app/routes/dashboard.ts +6 -4
  31. package/app/routes/publish.ts +259 -45
  32. package/app/routes/settings.ts +92 -37
  33. package/app/routes/stories.ts +961 -5
  34. package/app/routes/terminal.ts +383 -31
  35. package/app/routes/wallet.ts +58 -30
  36. package/app/server.ts +47 -12
  37. package/app/vite.config.ts +6 -0
  38. package/app/web/components/CartoonNextAction.tsx +145 -0
  39. package/app/web/components/CartoonPreview.tsx +267 -0
  40. package/app/web/components/CartoonPublishPage.tsx +407 -0
  41. package/app/web/components/CartoonPublishPreview.tsx +121 -0
  42. package/app/web/components/CartoonStepGuide.tsx +90 -0
  43. package/app/web/components/CartoonWorkflowNav.tsx +68 -0
  44. package/app/web/components/CodexImportPicker.tsx +230 -0
  45. package/app/web/components/CutListPanel.tsx +1337 -0
  46. package/app/web/components/Dashboard.tsx +15 -6
  47. package/app/web/components/EpisodesPage.tsx +80 -0
  48. package/app/web/components/FinishEpisodePanel.tsx +151 -0
  49. package/app/web/components/Layout.tsx +7 -4
  50. package/app/web/components/LetteringEditor.tsx +1182 -0
  51. package/app/web/components/PreviewPanel.tsx +952 -78
  52. package/app/web/components/Settings.tsx +63 -0
  53. package/app/web/components/StoriesPage.tsx +745 -33
  54. package/app/web/components/StoryBrowser.tsx +22 -14
  55. package/app/web/components/StoryInfoPage.tsx +266 -0
  56. package/app/web/components/StoryProgressPanel.tsx +446 -0
  57. package/app/web/components/TerminalPanel.tsx +233 -11
  58. package/app/web/components/WalletCard.tsx +110 -8
  59. package/app/web/components/WorkflowCoach.tsx +156 -0
  60. package/app/web/components/asset-image.tsx +114 -0
  61. package/app/web/components/asset-test-utils.ts +44 -0
  62. package/app/web/components/export-cut.ts +320 -0
  63. package/app/web/dist/assets/export-cut-che5mMWc.js +1 -0
  64. package/app/web/dist/assets/index-CcfChGEK.css +32 -0
  65. package/app/web/dist/assets/index-Dc2TQ3Ij.js +143 -0
  66. package/app/web/dist/index.html +2 -2
  67. package/app/web/lib/cartoon-publish-summary.ts +43 -0
  68. package/app/web/lib/codex-import.ts +94 -0
  69. package/app/web/lib/image-compress.ts +53 -0
  70. package/app/web/lib/import-image.ts +58 -0
  71. package/app/web/lib/publish-helpers.ts +385 -0
  72. package/app/web/lib/upload-retry.ts +130 -0
  73. package/app/web/lib/verify-public-title.ts +105 -0
  74. package/app/web/styles.css +9 -0
  75. package/bin/plotlink-ows.js +53 -16
  76. package/bin/startup-plan.cjs +58 -0
  77. package/lib/genres.ts +92 -0
  78. package/package.json +60 -20
  79. package/scripts/gen-schema-sql.mjs +49 -0
  80. package/scripts/package-hygiene.mjs +116 -0
  81. package/scripts/preflight.mjs +173 -0
  82. package/scripts/start-smoke.mjs +128 -0
  83. package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
  84. package/app/node_modules/.prisma/local-client/client.js +0 -5
  85. package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
  86. package/app/node_modules/.prisma/local-client/default.js +0 -5
  87. package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
  88. package/app/node_modules/.prisma/local-client/edge.js +0 -184
  89. package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
  90. package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
  91. package/app/node_modules/.prisma/local-client/index.js +0 -207
  92. package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
  93. package/app/node_modules/.prisma/local-client/package.json +0 -183
  94. package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
  95. package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
  96. package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
  97. package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
  98. package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
  99. package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
  100. package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
  101. package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
  102. package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
  103. package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
  104. package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
  105. package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
  106. package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
  107. package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
  108. package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
  109. package/app/node_modules/.prisma/local-client/wasm.js +0 -191
  110. package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
  111. package/app/web/dist/assets/index-DxATSk7X.js +0 -134
  112. package/packages/cli/node_modules/commander/LICENSE +0 -22
  113. package/packages/cli/node_modules/commander/Readme.md +0 -1149
  114. package/packages/cli/node_modules/commander/esm.mjs +0 -16
  115. package/packages/cli/node_modules/commander/index.js +0 -24
  116. package/packages/cli/node_modules/commander/lib/argument.js +0 -149
  117. package/packages/cli/node_modules/commander/lib/command.js +0 -2662
  118. package/packages/cli/node_modules/commander/lib/error.js +0 -39
  119. package/packages/cli/node_modules/commander/lib/help.js +0 -709
  120. package/packages/cli/node_modules/commander/lib/option.js +0 -367
  121. package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
  122. package/packages/cli/node_modules/commander/package-support.json +0 -16
  123. package/packages/cli/node_modules/commander/package.json +0 -82
  124. package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
  125. package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
  126. package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
  127. package/packages/cli/node_modules/resolve-from/index.js +0 -47
  128. package/packages/cli/node_modules/resolve-from/license +0 -9
  129. package/packages/cli/node_modules/resolve-from/package.json +0 -36
  130. package/packages/cli/node_modules/resolve-from/readme.md +0 -72
  131. package/packages/cli/node_modules/tsup/LICENSE +0 -21
  132. package/packages/cli/node_modules/tsup/README.md +0 -75
  133. package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
  134. package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
  135. package/packages/cli/node_modules/tsup/assets/package.json +0 -3
  136. package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
  137. package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
  138. package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
  139. package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
  140. package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
  141. package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
  142. package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
  143. package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
  144. package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
  145. package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
  146. package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
  147. package/packages/cli/node_modules/tsup/package.json +0 -99
  148. package/packages/cli/node_modules/tsup/schema.json +0 -362
  149. package/public/screenshot-1.png +0 -0
  150. package/public/screenshot-2.png +0 -0
  151. package/public/screenshot-3.png +0 -0
  152. 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
  });
@@ -1,6 +1,7 @@
1
1
  import { Hono } from "hono";
2
2
  import fs from "fs";
3
3
  import { ENV_FILE } from "../lib/paths";
4
+ import { nextPlotlinkWalletName, resolveActiveWallet, selectActiveWallet, toPublicActiveWallet } from "../lib/active-wallet";
4
5
 
5
6
  const envPath = ENV_FILE;
6
7
 
@@ -21,18 +22,8 @@ function readEnvPassphrase(): string | null {
21
22
  /** GET /api/wallet — get wallet info */
22
23
  wallet.get("/", async (c) => {
23
24
  try {
24
- const { getAgentWallet, getBaseAddress } = await import("../../lib/ows/wallet");
25
-
26
- // Try to find existing wallet
27
- const { listAgentWallets } = await import("../../lib/ows/wallet");
28
- const wallets = listAgentWallets();
29
- const plotlinkWallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
30
-
31
- if (!plotlinkWallet) {
32
- return c.json({ exists: false });
33
- }
34
-
35
- const address = getBaseAddress(plotlinkWallet);
25
+ const resolved = await resolveActiveWallet();
26
+ const activeWallet = resolved.activeWallet;
36
27
 
37
28
  // Fetch balances on Base via RPC
38
29
  let ethBalance = "0";
@@ -40,8 +31,8 @@ wallet.get("/", async (c) => {
40
31
  let plotBalance = "0";
41
32
  const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
42
33
 
43
- if (address) {
44
- const addrPadded = "000000000000000000000000" + address.slice(2).toLowerCase();
34
+ if (activeWallet?.address) {
35
+ const addrPadded = "000000000000000000000000" + activeWallet.address.slice(2).toLowerCase();
45
36
  const balanceOfSig = "0x70a08231" + addrPadded;
46
37
 
47
38
  try {
@@ -49,7 +40,7 @@ wallet.get("/", async (c) => {
49
40
  const ethRes = await fetch(rpcUrl, {
50
41
  method: "POST",
51
42
  headers: { "Content-Type": "application/json" },
52
- body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [address, "latest"] }),
43
+ body: JSON.stringify({ jsonrpc: "2.0", id: 1, method: "eth_getBalance", params: [activeWallet.address, "latest"] }),
53
44
  });
54
45
  const ethData = await ethRes.json() as { result?: string };
55
46
  if (ethData.result && ethData.result !== "0x" && ethData.result !== "0x0") {
@@ -82,15 +73,27 @@ wallet.get("/", async (c) => {
82
73
  } catch { /* balance fetch best-effort */ }
83
74
  }
84
75
 
76
+ if (!activeWallet) {
77
+ return c.json({
78
+ exists: resolved.wallets.length > 0,
79
+ selectionRequired: resolved.selectionRequired,
80
+ error: resolved.error,
81
+ wallets: resolved.wallets,
82
+ });
83
+ }
84
+
85
85
  return c.json({
86
86
  exists: true,
87
- walletId: plotlinkWallet.id,
88
- name: plotlinkWallet.name,
89
- address,
87
+ walletId: activeWallet.walletId,
88
+ name: activeWallet.name,
89
+ address: activeWallet.address,
90
+ activeWallet: toPublicActiveWallet(activeWallet),
91
+ selectionRequired: false,
92
+ wallets: resolved.wallets,
90
93
  ethBalance,
91
94
  usdcBalance,
92
95
  plotBalance,
93
- accounts: plotlinkWallet.accounts,
96
+ accounts: activeWallet.wallet.accounts,
94
97
  });
95
98
  } catch (err: unknown) {
96
99
  const message = err instanceof Error ? err.message : "Failed to get wallet";
@@ -98,6 +101,30 @@ wallet.get("/", async (c) => {
98
101
  }
99
102
  });
100
103
 
104
+ /** POST /api/wallet/active — select active OWS wallet */
105
+ wallet.post("/active", async (c) => {
106
+ const body = await c.req.json<{ walletId?: string; name?: string; address?: string }>();
107
+ if (!body.walletId && !body.name && !body.address) {
108
+ return c.json({ error: "walletId, name, or address required" }, 400);
109
+ }
110
+
111
+ const resolved = await selectActiveWallet(body);
112
+ if (!resolved.activeWallet) {
113
+ return c.json({
114
+ error: resolved.error || "Could not select wallet",
115
+ selectionRequired: resolved.selectionRequired,
116
+ wallets: resolved.wallets,
117
+ }, 400);
118
+ }
119
+
120
+ return c.json({
121
+ ok: true,
122
+ activeWallet: toPublicActiveWallet(resolved.activeWallet),
123
+ wallets: resolved.wallets,
124
+ selectionRequired: false,
125
+ });
126
+ });
127
+
101
128
  /** POST /api/wallet/create — create OWS wallet */
102
129
  wallet.post("/create", async (c) => {
103
130
  try {
@@ -106,20 +133,21 @@ wallet.post("/create", async (c) => {
106
133
  return c.json({ error: "Passphrase not configured" }, 400);
107
134
  }
108
135
 
109
- const { createAgentWallet, getBaseAddress, listAgentWallets } = await import("../../lib/ows/wallet");
136
+ const { createAgentWallet, listAgentWallets } = await import("../../lib/ows/wallet");
110
137
 
111
- // Check if wallet already exists
112
138
  const wallets = listAgentWallets();
113
- const existing = wallets.find((w) => w.name.startsWith("plotlink-writer"));
114
- if (existing) {
115
- const address = getBaseAddress(existing);
116
- return c.json({ walletId: existing.id, address, alreadyExisted: true });
117
- }
118
-
119
- const wallet = createAgentWallet("plotlink-writer", passphrase);
120
- const address = getBaseAddress(wallet);
139
+ const name = nextPlotlinkWalletName(wallets);
140
+ const createdWallet = createAgentWallet(name, passphrase);
141
+ const resolved = await selectActiveWallet({ walletId: createdWallet.id, name: createdWallet.name });
121
142
 
122
- return c.json({ walletId: wallet.id, address, alreadyExisted: false });
143
+ return c.json({
144
+ walletId: resolved.activeWallet?.walletId ?? createdWallet.id,
145
+ name: createdWallet.name,
146
+ address: resolved.activeWallet?.address,
147
+ activeWallet: resolved.activeWallet ? toPublicActiveWallet(resolved.activeWallet) : null,
148
+ wallets: resolved.wallets,
149
+ alreadyExisted: false,
150
+ });
123
151
  } catch (err: unknown) {
124
152
  const message = err instanceof Error ? err.message : "Wallet creation failed";
125
153
  return c.json({ error: message }, 500);