plotlink-ows 0.1.18 → 1.0.0

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.
@@ -78,6 +78,16 @@ publish.post("/file", async (c) => {
78
78
  return c.json({ error: "title and content required" }, 400);
79
79
  }
80
80
 
81
+ // Enforce character limits
82
+ const isGenesis = body.fileName === "genesis.md";
83
+ const isPlot = /^plot-\d+\.md$/.test(body.fileName);
84
+ const charLimit = isGenesis ? 1000 : isPlot ? 10000 : null;
85
+ if (charLimit && body.content.length > charLimit) {
86
+ return c.json({
87
+ error: `Content exceeds ${charLimit.toLocaleString()} character limit (${body.content.length.toLocaleString()} chars). Reduce content before publishing.`,
88
+ }, 400);
89
+ }
90
+
81
91
  // Get wallet
82
92
  let wallets;
83
93
  try {
@@ -92,7 +102,7 @@ publish.post("/file", async (c) => {
92
102
  console.log("[publish/file] Starting publish for", body.storyName, body.fileName, "wallet:", wallet.name);
93
103
 
94
104
  // Determine if this is genesis (createStoryline) or plot (chainPlot)
95
- const isPlot = body.fileName.match(/^plot-\d+\.md$/);
105
+ // isPlot already defined above from validation
96
106
 
97
107
  return streamSSE(c, async (stream) => {
98
108
  try {
@@ -127,8 +137,10 @@ publish.post("/file", async (c) => {
127
137
  step: "done",
128
138
  txHash: result.txHash,
129
139
  storylineId: result.storylineId,
140
+ plotIndex: result.plotIndex,
130
141
  contentCid: result.contentCid,
131
142
  gasCost: result.gasCost,
143
+ indexError: result.indexError,
132
144
  }),
133
145
  });
134
146
  } catch (err: unknown) {
@@ -140,4 +152,43 @@ publish.post("/file", async (c) => {
140
152
  });
141
153
  });
142
154
 
155
+ /** POST /api/publish/retry-index — retry indexing for a published file */
156
+ publish.post("/retry-index", async (c) => {
157
+ const body = await c.req.json<{
158
+ storyName: string;
159
+ fileName: string;
160
+ txHash: string;
161
+ content: string;
162
+ storylineId?: number;
163
+ }>();
164
+
165
+ if (!body.txHash || !body.content) {
166
+ return c.json({ error: "txHash and content required" }, 400);
167
+ }
168
+
169
+ const PLOTLINK_URL = process.env.NEXT_PUBLIC_APP_URL || "https://plotlink.xyz";
170
+ const isPlot = /^plot-\d+\.md$/.test(body.fileName);
171
+ const endpoint = isPlot ? "plot" : "storyline";
172
+ const indexBody = isPlot
173
+ ? { txHash: body.txHash, content: body.content }
174
+ : { txHash: body.txHash, content: body.content, genre: undefined };
175
+
176
+ try {
177
+ const indexRes = await fetch(`${PLOTLINK_URL}/api/index/${endpoint}`, {
178
+ method: "POST",
179
+ headers: { "Content-Type": "application/json" },
180
+ body: JSON.stringify(indexBody),
181
+ });
182
+ const indexData = await indexRes.json().catch(() => ({})) as Record<string, string>;
183
+ if (!indexRes.ok || indexData.error) {
184
+ const error = indexData.error || `Indexing failed: HTTP ${indexRes.status}`;
185
+ return c.json({ ok: false, error });
186
+ }
187
+ return c.json({ ok: true });
188
+ } catch (err) {
189
+ const message = err instanceof Error ? err.message : "Indexing request failed";
190
+ return c.json({ ok: false, error: message });
191
+ }
192
+ });
193
+
143
194
  export { publish as publishRoutes };
@@ -0,0 +1,194 @@
1
+ import { Hono } from "hono";
2
+ import { createPublicClient, createWalletClient, http, decodeEventLog } from "viem";
3
+ import { base } from "viem/chains";
4
+ import { erc8004Abi } from "../../packages/cli/src/sdk/abi";
5
+ import { listAgentWallets, getBaseAddress } from "../../lib/ows/wallet";
6
+ import { createOwsAccount } from "../lib/publish";
7
+ import { db } from "../db";
8
+ import {
9
+ signMessage as owsSignMsg,
10
+ } from "@open-wallet-standard/core";
11
+
12
+ const ERC_8004 = "0x8004A169FB4a3325136EB29fA0ceB6D2e539a432" as const;
13
+ const rpcUrl = process.env.NEXT_PUBLIC_RPC_URL || "https://mainnet.base.org";
14
+
15
+ const publicClient = createPublicClient({
16
+ chain: base,
17
+ transport: http(rpcUrl),
18
+ });
19
+
20
+ const settings = new Hono();
21
+
22
+ /** POST /api/settings/generate-binding — generate wallet binding proof for PlotLink */
23
+ settings.post("/generate-binding", async (c) => {
24
+ const body = await c.req.json<{ humanWallet: string }>();
25
+
26
+ if (!body.humanWallet || !/^0x[a-fA-F0-9]{40}$/.test(body.humanWallet)) {
27
+ return c.json({ error: "Valid wallet address required (0x...)" }, 400);
28
+ }
29
+
30
+ try {
31
+ const wallets = listAgentWallets();
32
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
33
+ if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
34
+
35
+ const owsWallet = getBaseAddress(wallet);
36
+ if (!owsWallet) return c.json({ error: "No EVM address on wallet" }, 400);
37
+
38
+ const message = `I authorize ${body.humanWallet} as my PlotLink owner. Wallet: ${owsWallet}`;
39
+ const passphrase = process.env.OWS_PASSPHRASE;
40
+
41
+ const result = owsSignMsg(wallet.name, "eip155:8453", message, passphrase);
42
+ const signature = result.signature.startsWith("0x") ? result.signature : `0x${result.signature}`;
43
+
44
+ return c.json({
45
+ message,
46
+ signature,
47
+ owsWallet,
48
+ });
49
+ } catch (err: unknown) {
50
+ const msg = err instanceof Error ? err.message : "Failed to generate binding proof";
51
+ return c.json({ error: msg }, 500);
52
+ }
53
+ });
54
+
55
+ /** POST /api/settings/register-agent — OWS wallet self-registers on ERC-8004 */
56
+ settings.post("/register-agent", async (c) => {
57
+ const body = await c.req.json<{ name: string; description: string; genre?: string }>();
58
+
59
+ if (!body.name?.trim()) {
60
+ return c.json({ error: "Agent name is required" }, 400);
61
+ }
62
+ if (!body.description?.trim()) {
63
+ return c.json({ error: "Agent description is required" }, 400);
64
+ }
65
+
66
+ try {
67
+ const wallets = listAgentWallets();
68
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
69
+ if (!wallet) return c.json({ error: "No OWS wallet found. Create one in Wallet settings first." }, 400);
70
+
71
+ const owsAddress = getBaseAddress(wallet);
72
+ if (!owsAddress) return c.json({ error: "No EVM address on wallet" }, 400);
73
+
74
+ // Check if already registered
75
+ try {
76
+ const existingId = await publicClient.readContract({
77
+ address: ERC_8004,
78
+ abi: erc8004Abi,
79
+ functionName: "agentIdByWallet",
80
+ args: [owsAddress as `0x${string}`],
81
+ }) as bigint;
82
+ if (existingId > 0n) {
83
+ return c.json({ error: `Already registered as Agent #${existingId}` }, 400);
84
+ }
85
+ } catch { /* not registered — continue */ }
86
+
87
+ // Build agentURI as inline JSON
88
+ const agentURI = JSON.stringify({
89
+ name: body.name.trim(),
90
+ description: body.description.trim(),
91
+ ...(body.genre?.trim() && { genre: body.genre.trim() }),
92
+ llmModel: "Claude",
93
+ registeredBy: "plotlink-ows",
94
+ registeredAt: new Date().toISOString(),
95
+ });
96
+
97
+ // Create OWS-backed wallet client and call register()
98
+ const account = createOwsAccount(wallet.name, owsAddress as `0x${string}`);
99
+ const walletClient = createWalletClient({ account, chain: base, transport: http(rpcUrl) });
100
+
101
+ const txHash = await walletClient.writeContract({
102
+ address: ERC_8004,
103
+ abi: erc8004Abi,
104
+ functionName: "register",
105
+ args: [agentURI],
106
+ });
107
+
108
+ // Wait for confirmation and decode Registered event
109
+ const receipt = await publicClient.waitForTransactionReceipt({
110
+ hash: txHash,
111
+ });
112
+
113
+ if (receipt.status === "reverted") {
114
+ return c.json({ error: "Transaction reverted on-chain" }, 500);
115
+ }
116
+
117
+ let agentId: number | undefined;
118
+ for (const log of receipt.logs) {
119
+ try {
120
+ const decoded = decodeEventLog({
121
+ abi: erc8004Abi,
122
+ data: log.data,
123
+ topics: log.topics,
124
+ });
125
+ if (decoded.eventName === "Registered") {
126
+ agentId = Number((decoded.args as { agentId: bigint }).agentId);
127
+ break;
128
+ }
129
+ } catch { /* not our event */ }
130
+ }
131
+
132
+ if (!agentId) {
133
+ return c.json({ error: "Transaction succeeded but Registered event not found" }, 500);
134
+ }
135
+
136
+ // Store agentId locally
137
+ await db.setting.upsert({
138
+ where: { key: "agent_id" },
139
+ update: { value: String(agentId) },
140
+ create: { key: "agent_id", value: String(agentId) },
141
+ });
142
+
143
+ return c.json({
144
+ agentId,
145
+ owsWallet: owsAddress,
146
+ txHash,
147
+ });
148
+ } catch (err: unknown) {
149
+ const msg = err instanceof Error ? err.message : "Registration failed";
150
+ return c.json({ error: msg }, 500);
151
+ }
152
+ });
153
+
154
+ /** GET /api/settings/link-status — check if OWS wallet is registered on ERC-8004 */
155
+ settings.get("/link-status", async (c) => {
156
+ try {
157
+ const wallets = listAgentWallets();
158
+ const wallet = wallets.find((w) => w.name.startsWith("plotlink-writer"));
159
+ if (!wallet) return c.json({ linked: false, error: "No wallet" });
160
+
161
+ const address = getBaseAddress(wallet);
162
+ if (!address) return c.json({ linked: false, error: "No EVM address" });
163
+
164
+ try {
165
+ const agentId = await publicClient.readContract({
166
+ address: ERC_8004,
167
+ abi: erc8004Abi,
168
+ functionName: "agentIdByWallet",
169
+ args: [address as `0x${string}`],
170
+ }) as bigint;
171
+
172
+ if (agentId > 0n) {
173
+ // Fetch NFT owner (ERC-721 ownerOf)
174
+ let owner: string | undefined;
175
+ try {
176
+ owner = await publicClient.readContract({
177
+ address: ERC_8004,
178
+ abi: [{ type: "function", name: "ownerOf", stateMutability: "view", inputs: [{ name: "tokenId", type: "uint256" }], outputs: [{ name: "", type: "address" }] }] as const,
179
+ functionName: "ownerOf",
180
+ args: [agentId],
181
+ }) as string;
182
+ } catch { /* best effort */ }
183
+ return c.json({ linked: true, agentId: Number(agentId), owsWallet: address, owner });
184
+ }
185
+ } catch { /* contract call may fail */ }
186
+
187
+ return c.json({ linked: false, owsWallet: address });
188
+ } catch (err: unknown) {
189
+ const message = err instanceof Error ? err.message : "Failed to check link status";
190
+ return c.json({ linked: false, error: message });
191
+ }
192
+ });
193
+
194
+ export { settings as settingsRoutes };
@@ -18,12 +18,14 @@ function safeName(name: string): string | null {
18
18
 
19
19
  interface FileStatus {
20
20
  file: string;
21
- status: "published" | "pending" | "draft";
21
+ status: "published" | "published-not-indexed" | "pending" | "draft";
22
22
  txHash?: string;
23
23
  storylineId?: number;
24
+ plotIndex?: number;
24
25
  contentCid?: string;
25
26
  publishedAt?: string;
26
27
  gasCost?: string;
28
+ indexError?: string;
27
29
  }
28
30
 
29
31
  interface StoryInfo {
@@ -56,7 +58,7 @@ function scanStory(storyDir: string, name: string): StoryInfo {
56
58
 
57
59
  const files: FileStatus[] = entries.map((file) => {
58
60
  const existing = publishStatus[file];
59
- if (existing?.status === "published") {
61
+ if (existing?.status === "published" || existing?.status === "published-not-indexed") {
60
62
  return existing;
61
63
  }
62
64
  return { file, status: "pending" as const };
@@ -65,7 +67,7 @@ function scanStory(storyDir: string, name: string): StoryInfo {
65
67
  const hasStructure = entries.includes("structure.md");
66
68
  const hasGenesis = entries.includes("genesis.md");
67
69
  const plotCount = entries.filter((f) => f.match(/^plot-\d+\.md$/)).length;
68
- const publishedCount = files.filter((f) => f.status === "published").length;
70
+ const publishedCount = files.filter((f) => f.status === "published" || f.status === "published-not-indexed").length;
69
71
 
70
72
  return { name, files, hasStructure, hasGenesis, plotCount, publishedCount };
71
73
  }
@@ -125,6 +127,42 @@ stories.get("/:name/:file", (c) => {
125
127
  return c.json({ ...status, content });
126
128
  });
127
129
 
130
+ /** PUT /api/stories/:name/:file — update file content */
131
+ stories.put("/:name/:file", async (c) => {
132
+ const name = safeName(c.req.param("name"));
133
+ const file = safeName(c.req.param("file"));
134
+ if (!name || !file) return c.json({ error: "Invalid path" }, 400);
135
+ if (!file.endsWith(".md")) return c.json({ error: "Only .md files can be edited" }, 400);
136
+
137
+ const filePath = path.join(STORIES_DIR, name, file);
138
+ if (!fs.existsSync(filePath)) {
139
+ return c.json({ error: "File not found" }, 404);
140
+ }
141
+
142
+ const body = await c.req.json<{ content: string }>();
143
+ if (typeof body.content !== "string") {
144
+ return c.json({ error: "Content must be a string" }, 400);
145
+ }
146
+
147
+ // Only write and reset status if content actually changed
148
+ const existingContent = fs.readFileSync(filePath, "utf-8");
149
+ if (body.content === existingContent) {
150
+ return c.json({ ok: true, unchanged: true });
151
+ }
152
+
153
+ fs.writeFileSync(filePath, body.content, "utf-8");
154
+
155
+ // Reset publish status to pending if file was previously published
156
+ const storyDir = path.join(STORIES_DIR, name);
157
+ const status = readPublishStatus(storyDir);
158
+ if (status[file] && (status[file].status === "published" || status[file].status === "published-not-indexed")) {
159
+ status[file].status = "pending";
160
+ writePublishStatus(storyDir, status);
161
+ }
162
+
163
+ return c.json({ ok: true });
164
+ });
165
+
128
166
  /** POST /api/stories/:name/:file/publish-status — update publish status after publishing */
129
167
  stories.post("/:name/:file/publish-status", async (c) => {
130
168
  const name = safeName(c.req.param("name"));
@@ -134,19 +172,48 @@ stories.post("/:name/:file/publish-status", async (c) => {
134
172
  const body = await c.req.json<{
135
173
  txHash: string;
136
174
  storylineId?: number;
175
+ plotIndex?: number;
137
176
  contentCid: string;
138
177
  gasCost?: string;
178
+ indexError?: string;
139
179
  }>();
140
180
 
141
181
  const status = readPublishStatus(storyDir);
182
+ const existing = status[file];
142
183
  status[file] = {
143
184
  file,
144
- status: "published",
145
- txHash: body.txHash,
146
- storylineId: body.storylineId,
147
- contentCid: body.contentCid,
148
- gasCost: body.gasCost,
185
+ status: body.indexError ? "published-not-indexed" : "published",
186
+ txHash: body.txHash || existing?.txHash,
187
+ storylineId: body.storylineId ?? existing?.storylineId,
188
+ plotIndex: body.plotIndex ?? existing?.plotIndex,
189
+ contentCid: body.contentCid || existing?.contentCid,
190
+ gasCost: body.gasCost || existing?.gasCost,
149
191
  publishedAt: new Date().toISOString(),
192
+ ...(body.indexError ? { indexError: body.indexError } : {}),
193
+ };
194
+ writePublishStatus(storyDir, status);
195
+
196
+ return c.json({ ok: true });
197
+ });
198
+
199
+ /** POST /api/stories/:name/:file/mark-not-indexed — manually mark as not indexed */
200
+ stories.post("/:name/:file/mark-not-indexed", async (c) => {
201
+ const name = safeName(c.req.param("name"));
202
+ const file = safeName(c.req.param("file"));
203
+ if (!name || !file) return c.json({ error: "Invalid path" }, 400);
204
+ const storyDir = path.join(STORIES_DIR, name);
205
+
206
+ const status = readPublishStatus(storyDir);
207
+ const existing = status[file];
208
+ if (!existing || (existing.status !== "published" && existing.status !== "published-not-indexed")) {
209
+ return c.json({ error: "File is not published" }, 400);
210
+ }
211
+
212
+ const body = await c.req.json<{ indexError?: string }>().catch(() => ({}));
213
+ status[file] = {
214
+ ...existing,
215
+ status: "published-not-indexed",
216
+ indexError: body.indexError || "Manually marked as not indexed",
150
217
  };
151
218
  writePublishStatus(storyDir, status);
152
219
 
@@ -1,59 +1,173 @@
1
1
  import { Hono } from "hono";
2
2
  import * as pty from "node-pty";
3
3
  import path from "path";
4
+ import fs from "fs";
4
5
  import { fileURLToPath } from "url";
6
+ import { randomUUID } from "crypto";
5
7
 
6
8
  const __dirname = path.dirname(fileURLToPath(import.meta.url));
7
9
  const STORIES_DIR = path.join(__dirname, "..", "..", "stories");
10
+ const MAX_SESSIONS = 5;
11
+ const SESSION_FILE = path.join(__dirname, "..", "..", "data", "terminal-sessions.json");
8
12
 
9
13
  const terminal = new Hono();
10
14
 
11
- // Active PTY sessions keyed by session ID
15
+ // Active PTY sessions keyed by story name
12
16
  const ptySessions = new Map<
13
17
  string,
14
- { term: pty.IPty; ws: WebSocket | null; state: "running" | "stopped" }
18
+ { term: pty.IPty; ws: WebSocket | null; state: "running" | "stopped"; sessionId: string }
15
19
  >();
16
20
 
17
- /** POST /api/terminal/spawn spawn Claude CLI in stories/ */
18
- terminal.post("/spawn", (c) => {
19
- const sessionId = "default";
20
-
21
- const existing = ptySessions.get(sessionId);
22
- if (existing?.term && existing.state === "running") {
23
- return c.json({ ok: true, pid: existing.term.pid, reused: true });
21
+ function safeName(name: string): string | null {
22
+ if (!name || name.includes("..") || name.includes("/") || name.includes("\\") || name.startsWith(".")) {
23
+ return null;
24
24
  }
25
+ return name;
26
+ }
27
+
28
+ /** Load stored session UUIDs from disk */
29
+ function loadSessionMap(): Record<string, string> {
30
+ try {
31
+ if (fs.existsSync(SESSION_FILE)) {
32
+ return JSON.parse(fs.readFileSync(SESSION_FILE, "utf-8"));
33
+ }
34
+ } catch { /* ignore */ }
35
+ return {};
36
+ }
25
37
 
38
+ /** Save session UUIDs to disk */
39
+ function saveSessionMap(map: Record<string, string>) {
26
40
  try {
27
- const shell = process.env.SHELL || "/bin/zsh";
28
- const term = pty.spawn(shell, ["-l", "-c", "claude"], {
29
- name: "xterm-256color",
30
- cols: 120,
31
- rows: 30,
32
- cwd: STORIES_DIR,
33
- env: process.env as Record<string, string>,
34
- });
35
-
36
- ptySessions.set(sessionId, { term, ws: null, state: "running" });
37
-
38
- term.onExit(({ exitCode }) => {
39
- const session = ptySessions.get(sessionId);
40
- if (session?.term === term) {
41
- session.state = "stopped";
42
- if (session.ws && session.ws.readyState <= 1) {
43
- session.ws.close(1000, `exited:${exitCode}`);
44
- }
45
- session.ws = null;
41
+ const dir = path.dirname(SESSION_FILE);
42
+ if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
43
+ fs.writeFileSync(SESSION_FILE, JSON.stringify(map, null, 2) + "\n");
44
+ } catch { /* ignore */ }
45
+ }
46
+
47
+ function spawnPty(storyName: string, opts?: { sessionId?: string; resume?: boolean }) {
48
+ const storyDir = path.join(STORIES_DIR, storyName);
49
+ if (!fs.existsSync(storyDir)) fs.mkdirSync(storyDir, { recursive: true });
50
+ const shell = process.env.SHELL || "/bin/zsh";
51
+
52
+ // Determine session ID
53
+ 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}"`;
63
+ } else {
64
+ // Fresh: always generate new UUID
65
+ sessionId = opts?.sessionId || randomUUID();
66
+ claudeCmd += ` --session-id "${sessionId}"`;
67
+ }
68
+
69
+ const term = pty.spawn(shell, ["-l", "-c", claudeCmd], {
70
+ name: "xterm-256color",
71
+ cols: 120,
72
+ rows: 30,
73
+ cwd: storyDir,
74
+ env: process.env as Record<string, string>,
75
+ });
76
+
77
+ // Persist session ID
78
+ sessionMap[storyName] = sessionId;
79
+ saveSessionMap(sessionMap);
80
+
81
+ const isResume = !!opts?.resume;
82
+ const spawnTime = Date.now();
83
+ const session = { term, ws: null as WebSocket | null, state: "running" as const, sessionId };
84
+ ptySessions.set(storyName, session);
85
+
86
+ term.onExit(({ exitCode }) => {
87
+ const s = ptySessions.get(storyName);
88
+ if (s?.term !== term) return;
89
+
90
+ // If a resumed session exits quickly (< 5s), signal client to auto-reconnect fresh
91
+ const elapsed = Date.now() - spawnTime;
92
+ if (isResume && elapsed < 5000 && exitCode !== 0) {
93
+ console.log(`Resume for "${storyName}" failed (exit ${exitCode} in ${elapsed}ms), signaling fresh fallback`);
94
+ ptySessions.delete(storyName);
95
+ if (s.ws && s.ws.readyState <= 1) {
96
+ // Close code 4000 = resume-failed, client should auto-reconnect fresh
97
+ s.ws.close(4000, "resume-failed");
46
98
  }
47
- });
99
+ s.ws = null;
100
+ return;
101
+ }
102
+
103
+ s.state = "stopped";
104
+ if (s.ws && s.ws.readyState <= 1) {
105
+ s.ws.close(1000, `exited:${exitCode}`);
106
+ }
107
+ s.ws = null;
108
+ });
109
+
110
+ return session;
111
+ }
112
+
113
+ /** POST /api/terminal/spawn — spawn Claude CLI for a story */
114
+ terminal.post("/spawn", async (c) => {
115
+ const body = await c.req.json<{ storyName?: string; resume?: boolean }>().catch(() => ({}));
116
+ const storyName = safeName(body.storyName || "default");
117
+ if (!storyName) return c.json({ error: "Invalid story name" }, 400);
118
+
119
+ const existing = ptySessions.get(storyName);
120
+ if (existing?.term && existing.state === "running") {
121
+ return c.json({ ok: true, pid: existing.term.pid, storyName, sessionId: existing.sessionId, reused: true });
122
+ }
123
+
124
+ // Enforce max concurrent sessions
125
+ const running = [...ptySessions.values()].filter((s) => s.state === "running").length;
126
+ if (running >= MAX_SESSIONS) {
127
+ return c.json({ error: `Max ${MAX_SESSIONS} concurrent sessions`, ok: false }, 429);
128
+ }
48
129
 
49
- return c.json({ ok: true, pid: term.pid });
130
+ try {
131
+ const session = spawnPty(storyName, { resume: body.resume });
132
+ return c.json({ ok: true, pid: session.term.pid, storyName, sessionId: session.sessionId });
50
133
  } catch (err: unknown) {
51
134
  const message = err instanceof Error ? err.message : "Failed to spawn PTY";
52
135
  return c.json({ ok: false, error: message }, 500);
53
136
  }
54
137
  });
55
138
 
56
- /** POST /api/terminal/stopkill PTY */
139
+ /** GET /api/terminal/session/:storyNameget stored session ID for a story */
140
+ terminal.get("/session/:storyName", (c) => {
141
+ const storyName = safeName(c.req.param("storyName"));
142
+ if (!storyName) return c.json({ error: "Invalid story name" }, 400);
143
+
144
+ const sessionMap = loadSessionMap();
145
+ const sessionId = sessionMap[storyName] || null;
146
+ const active = ptySessions.get(storyName);
147
+
148
+ return c.json({
149
+ sessionId,
150
+ running: !!active && active.state === "running",
151
+ });
152
+ });
153
+
154
+ /** DELETE /api/terminal/:storyName — kill a story's PTY */
155
+ terminal.delete("/:storyName", (c) => {
156
+ const storyName = safeName(c.req.param("storyName"));
157
+ if (!storyName) return c.json({ error: "Invalid story name" }, 400);
158
+
159
+ const session = ptySessions.get(storyName);
160
+ if (session?.term && session.state === "running") {
161
+ session.term.kill();
162
+ session.state = "stopped";
163
+ ptySessions.delete(storyName);
164
+ return c.json({ ok: true });
165
+ }
166
+ ptySessions.delete(storyName);
167
+ return c.json({ ok: true, message: "not running" });
168
+ });
169
+
170
+ /** POST /api/terminal/stop — kill PTY (legacy, kills default) */
57
171
  terminal.post("/stop", (c) => {
58
172
  const session = ptySessions.get("default");
59
173
  if (session?.term && session.state === "running") {
@@ -64,48 +178,38 @@ terminal.post("/stop", (c) => {
64
178
  return c.json({ ok: true, message: "not running" });
65
179
  });
66
180
 
67
- /** GET /api/terminal/status */
181
+ /** GET /api/terminal/status — list all sessions */
68
182
  terminal.get("/status", (c) => {
69
- const session = ptySessions.get("default");
70
- return c.json({
71
- running: session?.state === "running",
72
- pid: session?.term?.pid ?? null,
73
- });
183
+ const sessions: Record<string, { running: boolean; pid: number | null; sessionId: string }> = {};
184
+ for (const [name, session] of ptySessions) {
185
+ sessions[name] = {
186
+ running: session.state === "running",
187
+ pid: session.term?.pid ?? null,
188
+ sessionId: session.sessionId,
189
+ };
190
+ }
191
+ return c.json({ sessions });
74
192
  });
75
193
 
76
194
  /**
77
- * Attach a raw WebSocket to the PTY session.
195
+ * Attach a raw WebSocket to a story's PTY session.
78
196
  * Called from server.ts WebSocket upgrade handler.
79
197
  */
80
- export function attachTerminalWs(ws: WebSocket) {
81
- const sessionId = "default";
82
- let session = ptySessions.get(sessionId);
198
+ export function attachTerminalWs(ws: WebSocket, storyName?: string, resume?: boolean) {
199
+ const name = storyName && safeName(storyName) ? storyName : "default";
200
+ let session = ptySessions.get(name);
83
201
 
84
202
  // Lazy spawn if no PTY exists
85
203
  if (!session || session.state !== "running") {
204
+ // Enforce max concurrent sessions
205
+ const running = [...ptySessions.values()].filter((s) => s.state === "running").length;
206
+ if (running >= MAX_SESSIONS) {
207
+ ws.close(1013, "max-sessions");
208
+ return;
209
+ }
210
+
86
211
  try {
87
- const shell = process.env.SHELL || "/bin/zsh";
88
- const term = pty.spawn(shell, ["-l", "-c", "claude"], {
89
- name: "xterm-256color",
90
- cols: 120,
91
- rows: 30,
92
- cwd: STORIES_DIR,
93
- env: process.env as Record<string, string>,
94
- });
95
-
96
- session = { term, ws: null, state: "running" };
97
- ptySessions.set(sessionId, session);
98
-
99
- term.onExit(({ exitCode }) => {
100
- const s = ptySessions.get(sessionId);
101
- if (s?.term === term) {
102
- s.state = "stopped";
103
- if (s.ws && s.ws.readyState <= 1) {
104
- s.ws.close(1000, `exited:${exitCode}`);
105
- }
106
- s.ws = null;
107
- }
108
- });
212
+ session = spawnPty(name, { resume });
109
213
  } catch (err) {
110
214
  console.error("PTY spawn failed:", err);
111
215
  ws.close(1011, "pty-spawn-failed");