plotlink-ows 1.0.10 → 1.0.14

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.
@@ -101,6 +101,59 @@ stories.get("/", (c) => {
101
101
  return c.json({ stories: result });
102
102
  });
103
103
 
104
+ const ARCHIVED_DIR = path.join(STORIES_DIR, ".archived");
105
+
106
+ /** GET /api/stories/archived — list archived stories */
107
+ stories.get("/archived", (c) => {
108
+ if (!fs.existsSync(ARCHIVED_DIR)) {
109
+ return c.json({ stories: [] });
110
+ }
111
+
112
+ const dirs = fs.readdirSync(ARCHIVED_DIR, { withFileTypes: true })
113
+ .filter((d) => d.isDirectory() && !d.name.startsWith("."))
114
+ .map((d) => d.name)
115
+ .sort();
116
+
117
+ const result = dirs.map((name) => scanStory(path.join(ARCHIVED_DIR, name), name));
118
+ return c.json({ stories: result });
119
+ });
120
+
121
+ /** POST /api/stories/archive — move story to .archived/ */
122
+ stories.post("/archive", async (c) => {
123
+ const body = await c.req.json<{ name: string }>();
124
+ const name = safeName(body.name);
125
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
126
+
127
+ const src = path.join(STORIES_DIR, name);
128
+ if (!fs.existsSync(src)) return c.json({ error: "Story not found" }, 404);
129
+ if (!fs.existsSync(path.join(src, "structure.md"))) {
130
+ return c.json({ error: "Only stories with structure.md can be archived" }, 400);
131
+ }
132
+
133
+ fs.mkdirSync(ARCHIVED_DIR, { recursive: true });
134
+ const dest = path.join(ARCHIVED_DIR, name);
135
+ if (fs.existsSync(dest)) return c.json({ error: "Already archived" }, 409);
136
+
137
+ fs.renameSync(src, dest);
138
+ return c.json({ ok: true });
139
+ });
140
+
141
+ /** POST /api/stories/restore — move story back from .archived/ */
142
+ stories.post("/restore", async (c) => {
143
+ const body = await c.req.json<{ name: string }>();
144
+ const name = safeName(body.name);
145
+ if (!name) return c.json({ error: "Invalid story name" }, 400);
146
+
147
+ const src = path.join(ARCHIVED_DIR, name);
148
+ if (!fs.existsSync(src)) return c.json({ error: "Archived story not found" }, 404);
149
+
150
+ const dest = path.join(STORIES_DIR, name);
151
+ if (fs.existsSync(dest)) return c.json({ error: "Story already exists" }, 409);
152
+
153
+ fs.renameSync(src, dest);
154
+ return c.json({ ok: true });
155
+ });
156
+
104
157
  /** GET /api/stories/:name — single story detail */
105
158
  stories.get("/:name", (c) => {
106
159
  const name = safeName(c.req.param("name"));
@@ -167,6 +167,32 @@ terminal.delete("/:storyName", (c) => {
167
167
  return c.json({ ok: true, message: "not running" });
168
168
  });
169
169
 
170
+ /** DELETE /api/terminal/:storyName/discard — discard session, kill PTY, clean up metadata */
171
+ terminal.delete("/:storyName/discard", (c) => {
172
+ const storyName = safeName(c.req.param("storyName"));
173
+ if (!storyName) return c.json({ error: "Invalid story name" }, 400);
174
+
175
+ const session = ptySessions.get(storyName);
176
+ if (session?.term && session.state === "running") {
177
+ // Send exit gracefully, then kill
178
+ try { session.term.write("exit\n"); } catch { /* ignore */ }
179
+ setTimeout(() => {
180
+ try { session.term.kill(); } catch { /* ignore */ }
181
+ }, 500);
182
+ session.state = "stopped";
183
+ }
184
+ ptySessions.delete(storyName);
185
+
186
+ // Remove session metadata from terminal-sessions.json
187
+ const sessionMap = loadSessionMap();
188
+ if (sessionMap[storyName]) {
189
+ delete sessionMap[storyName];
190
+ saveSessionMap(sessionMap);
191
+ }
192
+
193
+ return c.json({ ok: true });
194
+ });
195
+
170
196
  /** POST /api/terminal/stop — kill PTY (legacy, kills default) */
171
197
  terminal.post("/stop", (c) => {
172
198
  const session = ptySessions.get("default");
@@ -9,7 +9,7 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
9
9
  const [savingPassphrase, setSavingPassphrase] = useState(false);
10
10
 
11
11
  // Agent identity registration
12
- const [linkStatus, setLinkStatus] = useState<{ linked: boolean; agentId?: number; owsWallet?: string; owner?: string } | null>(null);
12
+ const [linkStatus, setLinkStatus] = useState<{ linked: boolean; agentId?: number; owsWallet?: string; owner?: string; txHash?: string } | null>(null);
13
13
  const [agentName, setAgentName] = useState("AI Writer");
14
14
  const [agentDescription, setAgentDescription] = useState("");
15
15
  const [agentGenre, setAgentGenre] = useState("");
@@ -52,7 +52,7 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
52
52
  });
53
53
  const data = await res.json();
54
54
  if (!res.ok) throw new Error(data.error || "Registration failed");
55
- setLinkStatus({ linked: true, agentId: data.agentId, owsWallet: data.owsWallet });
55
+ setLinkStatus({ linked: true, agentId: data.agentId, owsWallet: data.owsWallet, txHash: data.txHash });
56
56
  } catch (err: unknown) {
57
57
  setRegisterError(err instanceof Error ? err.message : "Registration failed");
58
58
  }
@@ -141,8 +141,15 @@ export function Settings({ token, onLogout }: { token: string; onLogout: () => v
141
141
  Owner: {linkStatus.owner.slice(0, 6)}...{linkStatus.owner.slice(-4)}
142
142
  </p>
143
143
  )}
144
+ {linkStatus.txHash && (
145
+ <p className="text-muted text-xs">
146
+ <a href={`https://basescan.org/tx/${linkStatus.txHash}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
147
+ View transaction on BaseScan
148
+ </a>
149
+ </p>
150
+ )}
144
151
  <p className="text-muted text-xs">
145
- <a href={`https://plotlink.xyz/agents/${linkStatus.agentId}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
152
+ <a href={`https://plotlink.xyz/profile/${linkStatus.owsWallet}`} target="_blank" rel="noopener noreferrer" className="text-accent underline">
146
153
  View agent profile on plotlink.xyz
147
154
  </a>
148
155
  </p>
@@ -278,6 +278,42 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
278
278
  }
279
279
  }, []);
280
280
 
281
+ // Track confirmed stories (those with structure.md) for Archive gating
282
+ const [confirmedStories, setConfirmedStories] = useState<Set<string>>(new Set());
283
+ useEffect(() => {
284
+ authFetch("/api/stories").then((res) => res.ok ? res.json() : null).then((data) => {
285
+ if (data?.stories) {
286
+ setConfirmedStories(new Set(
287
+ (data.stories as { name: string; hasStructure: boolean }[])
288
+ .filter((s) => s.hasStructure)
289
+ .map((s) => s.name)
290
+ ));
291
+ }
292
+ }).catch(() => {});
293
+ const interval = setInterval(async () => {
294
+ try {
295
+ const res = await authFetch("/api/stories");
296
+ if (res.ok) {
297
+ const data = await res.json();
298
+ setConfirmedStories(new Set(
299
+ (data.stories as { name: string; hasStructure: boolean }[])
300
+ .filter((s) => s.hasStructure)
301
+ .map((s) => s.name)
302
+ ));
303
+ }
304
+ } catch { /* ignore */ }
305
+ }, 5000);
306
+ return () => clearInterval(interval);
307
+ }, [authFetch]);
308
+
309
+ const handleArchiveStory = useCallback((name: string) => {
310
+ // Archive API already called by TerminalPanel — just clear selection
311
+ if (selectedStory === name) {
312
+ setSelectedStory(null);
313
+ setSelectedFile(null);
314
+ }
315
+ }, [selectedStory]);
316
+
281
317
  return (
282
318
  <div ref={containerRef} className="h-[calc(100vh-3.5rem)] flex">
283
319
  {/* Story Browser Sidebar */}
@@ -294,7 +330,7 @@ export function StoriesPage({ token, authFetch }: StoriesPageProps) {
294
330
 
295
331
  {/* Terminal — sized by ratio of available space */}
296
332
  <div className="min-w-0 border-r border-border" style={{ flex: `${ratio} 0 0` }}>
297
- <TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} />
333
+ <TerminalPanel token={token} storyName={selectedStory} authFetch={authFetch} onSelectStory={handleSelectStory} onDestroySession={handleDestroySession} onArchiveStory={handleArchiveStory} confirmedStories={confirmedStories} />
298
334
  </div>
299
335
 
300
336
  {/* Drag Handle */}
@@ -42,7 +42,9 @@ const STATUS_COLOR: Record<string, string> = {
42
42
 
43
43
  export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectFile, onNewStory, untitledSessions = [] }: StoryBrowserProps) {
44
44
  const [stories, setStories] = useState<StoryInfo[]>([]);
45
+ const [archivedStories, setArchivedStories] = useState<StoryInfo[]>([]);
45
46
  const [expanded, setExpanded] = useState<Set<string>>(new Set());
47
+ const [showArchives, setShowArchives] = useState(false);
46
48
 
47
49
  const loadStories = useCallback(async () => {
48
50
  try {
@@ -54,6 +56,30 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
54
56
  } catch { /* ignore */ }
55
57
  }, [authFetch]);
56
58
 
59
+ const loadArchivedStories = useCallback(async () => {
60
+ try {
61
+ const res = await authFetch("/api/stories/archived");
62
+ if (res.ok) {
63
+ const data = await res.json();
64
+ setArchivedStories(data.stories);
65
+ }
66
+ } catch { /* ignore */ }
67
+ }, [authFetch]);
68
+
69
+ const handleRestore = useCallback(async (name: string) => {
70
+ try {
71
+ const res = await authFetch("/api/stories/restore", {
72
+ method: "POST",
73
+ headers: { "Content-Type": "application/json" },
74
+ body: JSON.stringify({ name }),
75
+ });
76
+ if (res.ok) {
77
+ loadArchivedStories();
78
+ loadStories();
79
+ }
80
+ } catch { /* ignore */ }
81
+ }, [authFetch, loadArchivedStories, loadStories]);
82
+
57
83
  useEffect(() => {
58
84
  // eslint-disable-next-line react-hooks/set-state-in-effect -- initial load + polling
59
85
  loadStories();
@@ -61,6 +87,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
61
87
  return () => clearInterval(interval);
62
88
  }, [loadStories]);
63
89
 
90
+ // Load archived stories when archives view is shown
91
+ useEffect(() => {
92
+ if (showArchives) {
93
+ // eslint-disable-next-line react-hooks/set-state-in-effect -- initial load for archives
94
+ loadArchivedStories();
95
+ }
96
+ }, [showArchives, loadArchivedStories]);
97
+
64
98
  // Auto-expand selected story
65
99
  useEffect(() => {
66
100
  if (selectedStory) {
@@ -111,12 +145,62 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
111
145
  return [...files].sort((a, b) => order(a.file) - order(b.file));
112
146
  };
113
147
 
148
+ if (showArchives) {
149
+ return (
150
+ <div className="h-full flex flex-col">
151
+ <div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
152
+ <span className="text-xs font-mono text-muted">Archives</span>
153
+ <span className="text-xs text-muted">{archivedStories.length}</span>
154
+ </div>
155
+ <div className="px-3 py-2 border-b border-border">
156
+ <button
157
+ onClick={() => setShowArchives(false)}
158
+ className="w-full px-3 py-1.5 text-sm text-muted hover:text-foreground hover:bg-surface rounded flex items-center gap-1.5"
159
+ >
160
+ <span>&larr;</span>
161
+ <span>Back</span>
162
+ </button>
163
+ </div>
164
+ <div className="flex-1 min-h-0 overflow-y-auto">
165
+ {archivedStories.length === 0 ? (
166
+ <div className="p-3 text-sm text-muted">
167
+ <p>No archived stories.</p>
168
+ </div>
169
+ ) : (
170
+ archivedStories.map((story) => (
171
+ <div key={story.name} className="px-3 py-2 flex items-center justify-between hover:bg-surface">
172
+ <span className="text-sm font-medium truncate" title={story.name}>{story.title || story.name}</span>
173
+ <button
174
+ onClick={() => handleRestore(story.name)}
175
+ className="text-xs text-accent hover:text-accent-dim flex-shrink-0 ml-2"
176
+ >
177
+ Restore
178
+ </button>
179
+ </div>
180
+ ))
181
+ )}
182
+ </div>
183
+ </div>
184
+ );
185
+ }
186
+
114
187
  return (
115
188
  <div className="h-full flex flex-col">
116
189
  <div className="px-3 py-1.5 border-b border-border flex items-center justify-between">
117
190
  <span className="text-xs font-mono text-muted">Stories</span>
118
191
  <span className="text-xs text-muted">{stories.length}</span>
119
192
  </div>
193
+ {onNewStory && (
194
+ <div className="px-3 py-2 border-b border-border">
195
+ <button
196
+ onClick={onNewStory}
197
+ className="w-full px-3 py-1.5 text-sm bg-accent text-white rounded hover:bg-accent-dim flex items-center justify-center gap-1.5"
198
+ >
199
+ <span>+</span>
200
+ <span>New Story</span>
201
+ </button>
202
+ </div>
203
+ )}
120
204
  <div className="flex-1 min-h-0 overflow-y-auto">
121
205
  {/* Untitled new story sessions */}
122
206
  {untitledSessions.map((id) => (
@@ -135,7 +219,7 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
135
219
  {stories.length === 0 && untitledSessions.length === 0 ? (
136
220
  <div className="p-3 text-sm text-muted">
137
221
  <p>No stories yet.</p>
138
- <p className="mt-1 text-xs">Click &quot;+ New Story&quot; below to start writing.</p>
222
+ <p className="mt-1 text-xs">Click &quot;+ New Story&quot; above to start writing.</p>
139
223
  </div>
140
224
  ) : (
141
225
  stories.filter((s) => s.name !== "_example").map((story) => (
@@ -173,17 +257,14 @@ export function StoryBrowser({ authFetch, selectedStory, selectedFile, onSelectF
173
257
  ))
174
258
  )}
175
259
  </div>
176
- {onNewStory && (
177
- <div className="px-3 py-2 border-t border-border">
178
- <button
179
- onClick={onNewStory}
180
- className="w-full px-3 py-1.5 text-sm text-accent hover:bg-surface rounded flex items-center gap-1.5"
181
- >
182
- <span>+</span>
183
- <span>New Story</span>
184
- </button>
185
- </div>
186
- )}
260
+ <div className="px-3 py-2 border-t border-border">
261
+ <button
262
+ onClick={() => setShowArchives(true)}
263
+ className="w-full px-3 py-1.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded flex items-center justify-center gap-1.5"
264
+ >
265
+ <span>Archives</span>
266
+ </button>
267
+ </div>
187
268
  </div>
188
269
  );
189
270
  }
@@ -10,6 +10,8 @@ interface TerminalPanelProps {
10
10
  authFetch: (url: string, opts?: RequestInit) => Promise<Response>;
11
11
  onSelectStory?: (storyName: string) => void;
12
12
  onDestroySession?: (storyName: string) => void;
13
+ onArchiveStory?: (storyName: string) => void;
14
+ confirmedStories?: Set<string>;
13
15
  }
14
16
 
15
17
  interface TerminalSession {
@@ -90,14 +92,26 @@ async function loadScrollback(storyName: string): Promise<string | null> {
90
92
  });
91
93
  }
92
94
 
95
+ async function deleteScrollback(storyName: string): Promise<void> {
96
+ const db = await openDb();
97
+ return new Promise((resolve, reject) => {
98
+ const tx = db.transaction(STORE_NAME, "readwrite");
99
+ tx.objectStore(STORE_NAME).delete(storyName);
100
+ tx.oncomplete = () => { db.close(); resolve(); };
101
+ tx.onerror = () => { db.close(); reject(tx.error); };
102
+ });
103
+ }
104
+
93
105
  // Sessions live outside React state to avoid ref-in-effect lint issues
94
106
  const sessions = new Map<string, TerminalSession>();
95
107
 
96
- export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession }: TerminalPanelProps) {
108
+ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDestroySession, onArchiveStory, confirmedStories }: TerminalPanelProps) {
97
109
  const wrapperRef = useRef<HTMLDivElement>(null);
98
110
  const authFetchRef = useRef(authFetch);
99
111
  const [sessionList, setSessionList] = useState<string[]>([]);
100
112
  const [disconnected, setDisconnected] = useState<Set<string>>(new Set());
113
+ const [confirmingDiscard, setConfirmingDiscard] = useState<string | null>(null);
114
+ const [confirmingArchive, setConfirmingArchive] = useState<string | null>(null);
101
115
 
102
116
  const connectWsRef = useRef<(name: string, session: TerminalSession, resume: boolean) => void>(() => {});
103
117
 
@@ -287,6 +301,32 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
287
301
  onDestroySession?.(name);
288
302
  }, [authFetch, onDestroySession]);
289
303
 
304
+ /** Discard an untitled session: send exit, kill PTY, delete scrollback & session metadata */
305
+ const discardSession = useCallback((name: string) => {
306
+ const session = sessions.get(name);
307
+ if (!session) return;
308
+
309
+ // Send exit command gracefully before killing
310
+ if (session.ws?.readyState === WebSocket.OPEN) {
311
+ session.ws.send("exit\n");
312
+ }
313
+
314
+ // Delete scrollback instead of saving
315
+ deleteScrollback(name).catch(() => {});
316
+
317
+ session.observer.disconnect();
318
+ if (session.ws) session.ws.close();
319
+ session.term.dispose();
320
+ session.container.remove();
321
+ sessions.delete(name);
322
+ setSessionList((prev) => prev.filter((s) => s !== name));
323
+ setDisconnected((prev) => { const next = new Set(prev); next.delete(name); return next; });
324
+
325
+ // Use discard endpoint to kill PTY and clean up session metadata
326
+ authFetch(`/api/terminal/${encodeURIComponent(name)}/discard`, { method: "DELETE" }).catch(() => {});
327
+ onDestroySession?.(name);
328
+ }, [authFetch, onDestroySession]);
329
+
290
330
  // Auto-spawn + show/hide when story changes
291
331
  useEffect(() => {
292
332
  if (!storyName) return;
@@ -373,7 +413,11 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
373
413
  <button
374
414
  onClick={(e) => {
375
415
  e.stopPropagation();
376
- destroySession(name);
416
+ if (name.startsWith("_new_")) {
417
+ setConfirmingDiscard(name);
418
+ } else {
419
+ destroySession(name);
420
+ }
377
421
  }}
378
422
  className="ml-0.5 text-muted hover:text-error text-[10px] leading-none"
379
423
  title="Close terminal"
@@ -383,6 +427,22 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
383
427
  </div>
384
428
  ))
385
429
  }
430
+ {/* Cancel button for untitled / Archive button for confirmed stories */}
431
+ {storyName?.startsWith("_new_") ? (
432
+ <button
433
+ onClick={() => setConfirmingDiscard(storyName)}
434
+ className="ml-auto px-2 py-0.5 text-xs text-error hover:bg-surface rounded flex items-center gap-1 flex-shrink-0"
435
+ >
436
+ Cancel ×
437
+ </button>
438
+ ) : storyName && onArchiveStory && confirmedStories?.has(storyName) ? (
439
+ <button
440
+ onClick={() => setConfirmingArchive(storyName)}
441
+ className="ml-auto px-2 py-0.5 text-xs text-muted hover:text-foreground hover:bg-surface rounded flex items-center gap-1 flex-shrink-0"
442
+ >
443
+ Archive
444
+ </button>
445
+ ) : null}
386
446
  </div>
387
447
  )}
388
448
 
@@ -400,6 +460,76 @@ export function TerminalPanel({ token, storyName, authFetch, onSelectStory, onDe
400
460
  </div>
401
461
  )}
402
462
 
463
+ {/* Discard confirmation overlay */}
464
+ {confirmingDiscard && (
465
+ <div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
466
+ <div className="text-center space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-sm">
467
+ <p className="text-sm font-serif text-foreground font-medium">Discard this session?</p>
468
+ <p className="text-xs text-muted">
469
+ This session will be lost — your AI hasn&apos;t created a story structure yet.
470
+ </p>
471
+ <div className="flex items-center justify-center gap-2">
472
+ <button
473
+ onClick={() => setConfirmingDiscard(null)}
474
+ className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
475
+ >
476
+ Cancel
477
+ </button>
478
+ <button
479
+ onClick={() => {
480
+ const name = confirmingDiscard;
481
+ setConfirmingDiscard(null);
482
+ discardSession(name);
483
+ }}
484
+ className="px-4 py-1.5 bg-error text-white text-sm rounded hover:opacity-80"
485
+ >
486
+ Discard
487
+ </button>
488
+ </div>
489
+ </div>
490
+ </div>
491
+ )}
492
+
493
+ {/* Archive confirmation overlay */}
494
+ {confirmingArchive && (
495
+ <div className="absolute inset-0 flex items-center justify-center z-10" style={{ background: "rgba(240, 235, 225, 0.9)" }}>
496
+ <div className="text-center space-y-3 p-6 bg-surface border border-border rounded-lg shadow-lg max-w-sm">
497
+ <p className="text-sm font-serif text-foreground font-medium">Archive this story?</p>
498
+ <p className="text-xs text-muted">
499
+ You can restore it later from the Archives view.
500
+ </p>
501
+ <div className="flex items-center justify-center gap-2">
502
+ <button
503
+ onClick={() => setConfirmingArchive(null)}
504
+ className="px-4 py-1.5 border border-border text-sm rounded hover:bg-surface"
505
+ >
506
+ Cancel
507
+ </button>
508
+ <button
509
+ onClick={async () => {
510
+ const name = confirmingArchive;
511
+ setConfirmingArchive(null);
512
+ try {
513
+ const res = await authFetchRef.current("/api/stories/archive", {
514
+ method: "POST",
515
+ headers: { "Content-Type": "application/json" },
516
+ body: JSON.stringify({ name }),
517
+ });
518
+ if (res.ok) {
519
+ destroySession(name);
520
+ onArchiveStory?.(name);
521
+ }
522
+ } catch { /* ignore */ }
523
+ }}
524
+ className="px-4 py-1.5 bg-accent text-white text-sm rounded hover:bg-accent-dim"
525
+ >
526
+ Archive
527
+ </button>
528
+ </div>
529
+ </div>
530
+ </div>
531
+ )}
532
+
403
533
  {/* Reconnect overlay */}
404
534
  {isDisconnected && storyName && (
405
535
  <div className="absolute inset-0 flex items-center justify-center" style={{ background: "rgba(240, 235, 225, 0.9)" }}>