plotlink-ows 0.1.18 → 1.0.4
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 +167 -67
- package/app/lib/publish.ts +134 -32
- package/app/routes/dashboard.ts +64 -13
- package/app/routes/publish.ts +52 -1
- package/app/routes/settings.ts +194 -0
- package/app/routes/stories.ts +75 -8
- package/app/routes/terminal.ts +167 -63
- package/app/server.ts +7 -1
- package/app/web/components/Dashboard.tsx +83 -32
- package/app/web/components/PreviewPanel.tsx +280 -41
- package/app/web/components/Settings.tsx +227 -3
- package/app/web/components/StoriesPage.tsx +121 -8
- package/app/web/components/StoryBrowser.tsx +32 -8
- package/app/web/components/TerminalPanel.tsx +384 -78
- package/app/web/dist/assets/index-BuOxhUWG.css +32 -0
- package/app/web/dist/assets/index-De8CpT47.js +129 -0
- package/app/web/dist/index.html +2 -2
- package/app/web/styles.css +18 -0
- package/bin/plotlink-ows.js +21 -61
- package/package.json +21 -15
- package/scripts/fix-index-status.ts +93 -0
- package/app/web/dist/assets/index-D5gfwaEX.css +0 -32
- package/app/web/dist/assets/index-pBt5Q_bN.js +0 -117
package/app/routes/publish.ts
CHANGED
|
@@ -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
|
-
|
|
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 };
|
package/app/routes/stories.ts
CHANGED
|
@@ -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
|
-
|
|
148
|
-
|
|
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
|
|
package/app/routes/terminal.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
18
|
-
|
|
19
|
-
|
|
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
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
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
|
-
/**
|
|
139
|
+
/** GET /api/terminal/session/:storyName — get 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
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
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
|
|
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
|
|
82
|
-
let session = ptySessions.get(
|
|
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
|
-
|
|
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");
|