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.
- package/README.md +4 -0
- package/app/lib/agent-command.ts +85 -0
- package/app/lib/agent-readiness.ts +133 -0
- package/app/lib/apply-schema.ts +55 -0
- package/app/lib/bubble-text.ts +160 -0
- package/app/lib/cartoon-coach.ts +198 -0
- package/app/lib/cartoon-markdown.ts +83 -0
- package/app/lib/cartoon-prompt.ts +122 -0
- package/app/lib/cartoon-readiness.ts +811 -0
- package/app/lib/clean-image-sync.ts +245 -0
- package/app/lib/codex-images.ts +152 -0
- package/app/lib/cut-asset-diagnostics.ts +120 -0
- package/app/lib/cuts.ts +302 -0
- package/app/lib/fonts.ts +109 -0
- package/app/lib/generate-claude-md.ts +8 -1
- package/app/lib/generate-story-instructions.ts +731 -0
- package/app/lib/image-asset-validate.ts +123 -0
- package/app/lib/lettering-status.ts +133 -0
- package/app/lib/overlays.ts +637 -0
- package/app/lib/paths.ts +10 -0
- package/app/lib/public-title.ts +65 -0
- package/app/lib/publish.ts +16 -2
- package/app/lib/story-progress.ts +243 -0
- package/app/lib/terminal-protocol.ts +16 -0
- package/app/lib/terminal-redact.ts +50 -0
- package/app/prisma/schema.sql +25 -0
- package/app/routes/agent.ts +42 -0
- package/app/routes/codex-images.ts +67 -0
- package/app/routes/publish.ts +203 -22
- package/app/routes/stories.ts +961 -5
- package/app/routes/terminal.ts +383 -31
- package/app/server.ts +47 -12
- package/app/vite.config.ts +6 -0
- package/app/web/components/CartoonPreview.tsx +267 -0
- package/app/web/components/CartoonPublishPage.tsx +407 -0
- package/app/web/components/CartoonPublishPreview.tsx +121 -0
- package/app/web/components/CartoonStepGuide.tsx +90 -0
- package/app/web/components/CartoonWorkflowNav.tsx +68 -0
- package/app/web/components/CodexImportPicker.tsx +230 -0
- package/app/web/components/CutListPanel.tsx +1299 -0
- package/app/web/components/EpisodesPage.tsx +80 -0
- package/app/web/components/FinishEpisodePanel.tsx +151 -0
- package/app/web/components/Layout.tsx +7 -4
- package/app/web/components/LetteringEditor.tsx +1141 -0
- package/app/web/components/PreviewPanel.tsx +951 -78
- package/app/web/components/Settings.tsx +63 -0
- package/app/web/components/StoriesPage.tsx +710 -33
- package/app/web/components/StoryBrowser.tsx +22 -14
- package/app/web/components/StoryInfoPage.tsx +266 -0
- package/app/web/components/StoryProgressPanel.tsx +516 -0
- package/app/web/components/TerminalPanel.tsx +233 -11
- package/app/web/components/WorkflowCoach.tsx +128 -0
- package/app/web/components/asset-image.tsx +114 -0
- package/app/web/components/asset-test-utils.ts +44 -0
- package/app/web/components/export-cut.ts +320 -0
- package/app/web/dist/assets/export-cut-nKQ_n2-J.js +1 -0
- package/app/web/dist/assets/index-BAZGwVwj.js +143 -0
- package/app/web/dist/assets/index-DoXH2OlP.css +32 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/lib/cartoon-publish-summary.ts +43 -0
- package/app/web/lib/codex-import.ts +94 -0
- package/app/web/lib/image-compress.ts +53 -0
- package/app/web/lib/import-image.ts +58 -0
- package/app/web/lib/publish-helpers.ts +385 -0
- package/app/web/lib/upload-retry.ts +130 -0
- package/app/web/lib/verify-public-title.ts +105 -0
- package/app/web/styles.css +9 -0
- package/bin/plotlink-ows.js +53 -16
- package/bin/startup-plan.cjs +58 -0
- package/lib/genres.ts +92 -0
- package/package.json +60 -20
- package/scripts/gen-schema-sql.mjs +49 -0
- package/scripts/package-hygiene.mjs +116 -0
- package/scripts/preflight.mjs +173 -0
- package/scripts/start-smoke.mjs +128 -0
- package/app/node_modules/.prisma/local-client/client.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/client.js +0 -5
- package/app/node_modules/.prisma/local-client/default.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/default.js +0 -5
- package/app/node_modules/.prisma/local-client/edge.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/edge.js +0 -184
- package/app/node_modules/.prisma/local-client/index-browser.js +0 -173
- package/app/node_modules/.prisma/local-client/index.d.ts +0 -3304
- package/app/node_modules/.prisma/local-client/index.js +0 -207
- package/app/node_modules/.prisma/local-client/libquery_engine-darwin-arm64.dylib.node +0 -0
- package/app/node_modules/.prisma/local-client/package.json +0 -183
- package/app/node_modules/.prisma/local-client/query_engine_bg.js +0 -2
- package/app/node_modules/.prisma/local-client/query_engine_bg.wasm +0 -0
- package/app/node_modules/.prisma/local-client/runtime/edge-esm.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/edge.js +0 -35
- package/app/node_modules/.prisma/local-client/runtime/index-browser.d.ts +0 -370
- package/app/node_modules/.prisma/local-client/runtime/index-browser.js +0 -17
- package/app/node_modules/.prisma/local-client/runtime/library.d.ts +0 -3982
- package/app/node_modules/.prisma/local-client/runtime/library.js +0 -147
- package/app/node_modules/.prisma/local-client/runtime/react-native.js +0 -84
- package/app/node_modules/.prisma/local-client/runtime/wasm-compiler-edge.js +0 -85
- package/app/node_modules/.prisma/local-client/runtime/wasm-engine-edge.js +0 -38
- package/app/node_modules/.prisma/local-client/schema.prisma +0 -21
- package/app/node_modules/.prisma/local-client/wasm-edge-light-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm-worker-loader.mjs +0 -5
- package/app/node_modules/.prisma/local-client/wasm.d.ts +0 -1
- package/app/node_modules/.prisma/local-client/wasm.js +0 -191
- package/app/web/dist/assets/index-B-2Ft7Yv.css +0 -32
- package/app/web/dist/assets/index-DxATSk7X.js +0 -134
- package/packages/cli/node_modules/commander/LICENSE +0 -22
- package/packages/cli/node_modules/commander/Readme.md +0 -1149
- package/packages/cli/node_modules/commander/esm.mjs +0 -16
- package/packages/cli/node_modules/commander/index.js +0 -24
- package/packages/cli/node_modules/commander/lib/argument.js +0 -149
- package/packages/cli/node_modules/commander/lib/command.js +0 -2662
- package/packages/cli/node_modules/commander/lib/error.js +0 -39
- package/packages/cli/node_modules/commander/lib/help.js +0 -709
- package/packages/cli/node_modules/commander/lib/option.js +0 -367
- package/packages/cli/node_modules/commander/lib/suggestSimilar.js +0 -101
- package/packages/cli/node_modules/commander/package-support.json +0 -16
- package/packages/cli/node_modules/commander/package.json +0 -82
- package/packages/cli/node_modules/commander/typings/esm.d.mts +0 -3
- package/packages/cli/node_modules/commander/typings/index.d.ts +0 -1045
- package/packages/cli/node_modules/resolve-from/index.d.ts +0 -31
- package/packages/cli/node_modules/resolve-from/index.js +0 -47
- package/packages/cli/node_modules/resolve-from/license +0 -9
- package/packages/cli/node_modules/resolve-from/package.json +0 -36
- package/packages/cli/node_modules/resolve-from/readme.md +0 -72
- package/packages/cli/node_modules/tsup/LICENSE +0 -21
- package/packages/cli/node_modules/tsup/README.md +0 -75
- package/packages/cli/node_modules/tsup/assets/cjs_shims.js +0 -13
- package/packages/cli/node_modules/tsup/assets/esm_shims.js +0 -9
- package/packages/cli/node_modules/tsup/assets/package.json +0 -3
- package/packages/cli/node_modules/tsup/dist/chunk-DI5BO6XE.js +0 -153
- package/packages/cli/node_modules/tsup/dist/chunk-JZ25TPTY.js +0 -42
- package/packages/cli/node_modules/tsup/dist/chunk-PEEXUWMS.js +0 -6
- package/packages/cli/node_modules/tsup/dist/chunk-TWFEYLU4.js +0 -352
- package/packages/cli/node_modules/tsup/dist/chunk-VGC3FXLU.js +0 -203
- package/packages/cli/node_modules/tsup/dist/cli-default.js +0 -12
- package/packages/cli/node_modules/tsup/dist/cli-main.js +0 -8
- package/packages/cli/node_modules/tsup/dist/cli-node.js +0 -14
- package/packages/cli/node_modules/tsup/dist/index.d.ts +0 -511
- package/packages/cli/node_modules/tsup/dist/index.js +0 -1711
- package/packages/cli/node_modules/tsup/dist/rollup.js +0 -6949
- package/packages/cli/node_modules/tsup/package.json +0 -99
- package/packages/cli/node_modules/tsup/schema.json +0 -362
- package/public/screenshot-1.png +0 -0
- package/public/screenshot-2.png +0 -0
- package/public/screenshot-3.png +0 -0
- package/scripts/e2e-verify.ts +0 -1100
package/app/routes/terminal.ts
CHANGED
|
@@ -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
|
-
/**
|
|
27
|
-
|
|
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
|
|
37
|
-
function saveSessionMap(map: Record<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
|
-
|
|
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
|
-
//
|
|
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
|
-
|
|
55
|
-
|
|
56
|
-
//
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
//
|
|
65
|
-
|
|
66
|
-
|
|
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
|
-
|
|
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
|
|
78
|
-
|
|
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]
|
|
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<{
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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 {
|
|
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 {
|
|
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
|
-
//
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
//
|
|
139
|
-
|
|
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(
|
|
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
|
}
|
package/app/vite.config.ts
CHANGED
|
@@ -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: {
|