@assistkick/create 1.7.0 → 1.8.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.
Files changed (200) hide show
  1. package/dist/bin/create.js +0 -0
  2. package/package.json +9 -7
  3. package/templates/assistkick-product-system/.env.example +1 -0
  4. package/templates/assistkick-product-system/local.db +0 -0
  5. package/templates/assistkick-product-system/package.json +4 -2
  6. package/templates/assistkick-product-system/packages/backend/package.json +2 -0
  7. package/templates/assistkick-product-system/packages/backend/src/routes/agents.ts +165 -0
  8. package/templates/assistkick-product-system/packages/backend/src/routes/files.test.ts +358 -0
  9. package/templates/assistkick-product-system/packages/backend/src/routes/files.ts +356 -0
  10. package/templates/assistkick-product-system/packages/backend/src/routes/git.ts +96 -1
  11. package/templates/assistkick-product-system/packages/backend/src/routes/graph.ts +1 -0
  12. package/templates/assistkick-product-system/packages/backend/src/routes/kanban.ts +43 -4
  13. package/templates/assistkick-product-system/packages/backend/src/routes/pipeline.ts +200 -84
  14. package/templates/assistkick-product-system/packages/backend/src/routes/projects.ts +6 -3
  15. package/templates/assistkick-product-system/packages/backend/src/routes/terminal.ts +53 -17
  16. package/templates/assistkick-product-system/packages/backend/src/routes/video.ts +218 -0
  17. package/templates/assistkick-product-system/packages/backend/src/routes/workflow_groups.ts +119 -0
  18. package/templates/assistkick-product-system/packages/backend/src/routes/workflows.ts +154 -0
  19. package/templates/assistkick-product-system/packages/backend/src/server.ts +81 -9
  20. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.test.ts +489 -0
  21. package/templates/assistkick-product-system/packages/backend/src/services/agent_service.ts +416 -0
  22. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.test.ts +189 -0
  23. package/templates/assistkick-product-system/packages/backend/src/services/bundle_service.ts +182 -0
  24. package/templates/assistkick-product-system/packages/backend/src/services/init.ts +28 -78
  25. package/templates/assistkick-product-system/packages/backend/src/services/project_service.test.ts +16 -0
  26. package/templates/assistkick-product-system/packages/backend/src/services/project_service.ts +73 -2
  27. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.test.ts +4 -4
  28. package/templates/assistkick-product-system/packages/backend/src/services/project_workspace_service.ts +87 -11
  29. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.test.ts +210 -69
  30. package/templates/assistkick-product-system/packages/backend/src/services/pty_session_manager.ts +210 -215
  31. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.test.ts +162 -0
  32. package/templates/assistkick-product-system/packages/backend/src/services/ssh_key_service.ts +148 -0
  33. package/templates/assistkick-product-system/packages/backend/src/services/terminal_ws_handler.ts +11 -5
  34. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.test.ts +64 -0
  35. package/templates/assistkick-product-system/packages/backend/src/services/tts_service.ts +134 -0
  36. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.test.ts +256 -0
  37. package/templates/assistkick-product-system/packages/backend/src/services/video_render_service.ts +258 -0
  38. package/templates/assistkick-product-system/packages/backend/src/services/workflow_group_service.ts +106 -0
  39. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.test.ts +275 -0
  40. package/templates/assistkick-product-system/packages/backend/src/services/workflow_service.ts +222 -0
  41. package/templates/assistkick-product-system/packages/frontend/package-lock.json +3455 -0
  42. package/templates/assistkick-product-system/packages/frontend/package.json +6 -0
  43. package/templates/assistkick-product-system/packages/frontend/src/App.tsx +8 -0
  44. package/templates/assistkick-product-system/packages/frontend/src/api/client.ts +456 -16
  45. package/templates/assistkick-product-system/packages/frontend/src/api/client_files.test.ts +172 -0
  46. package/templates/assistkick-product-system/packages/frontend/src/api/client_video.test.ts +238 -0
  47. package/templates/assistkick-product-system/packages/frontend/src/components/AgentsView.tsx +307 -0
  48. package/templates/assistkick-product-system/packages/frontend/src/components/CoherenceView.tsx +82 -66
  49. package/templates/assistkick-product-system/packages/frontend/src/components/CompositionPlaceholder.tsx +97 -0
  50. package/templates/assistkick-product-system/packages/frontend/src/components/DesignSystemView.tsx +20 -0
  51. package/templates/assistkick-product-system/packages/frontend/src/components/EditorTabBar.tsx +57 -0
  52. package/templates/assistkick-product-system/packages/frontend/src/components/FileTree.tsx +313 -0
  53. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeContextMenu.tsx +61 -0
  54. package/templates/assistkick-product-system/packages/frontend/src/components/FileTreeInlineInput.tsx +73 -0
  55. package/templates/assistkick-product-system/packages/frontend/src/components/FilesView.tsx +404 -0
  56. package/templates/assistkick-product-system/packages/frontend/src/components/GitRepoModal.tsx +187 -56
  57. package/templates/assistkick-product-system/packages/frontend/src/components/GraphLegend.tsx +71 -73
  58. package/templates/assistkick-product-system/packages/frontend/src/components/GraphSettings.tsx +8 -8
  59. package/templates/assistkick-product-system/packages/frontend/src/components/GraphView.tsx +1 -1
  60. package/templates/assistkick-product-system/packages/frontend/src/components/InviteUserDialog.tsx +15 -11
  61. package/templates/assistkick-product-system/packages/frontend/src/components/KanbanView.tsx +202 -171
  62. package/templates/assistkick-product-system/packages/frontend/src/components/LoginPage.tsx +14 -14
  63. package/templates/assistkick-product-system/packages/frontend/src/components/ProjectSelector.tsx +54 -33
  64. package/templates/assistkick-product-system/packages/frontend/src/components/QaIssueSheet.tsx +32 -49
  65. package/templates/assistkick-product-system/packages/frontend/src/components/SidePanel.tsx +43 -48
  66. package/templates/assistkick-product-system/packages/frontend/src/components/TerminalView.tsx +121 -52
  67. package/templates/assistkick-product-system/packages/frontend/src/components/Toolbar.tsx +20 -14
  68. package/templates/assistkick-product-system/packages/frontend/src/components/UsersView.tsx +52 -52
  69. package/templates/assistkick-product-system/packages/frontend/src/components/VideoGallery.tsx +313 -0
  70. package/templates/assistkick-product-system/packages/frontend/src/components/VideographyView.tsx +250 -0
  71. package/templates/assistkick-product-system/packages/frontend/src/components/WorkflowsView.tsx +474 -0
  72. package/templates/assistkick-product-system/packages/frontend/src/components/ds/AccentBorderList.tsx +53 -0
  73. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Button.tsx +87 -0
  74. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonGroup.tsx +29 -0
  75. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ButtonShowcase.tsx +221 -0
  76. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CardGlass.tsx +141 -0
  77. package/templates/assistkick-product-system/packages/frontend/src/components/ds/CompletionRing.tsx +30 -0
  78. package/templates/assistkick-product-system/packages/frontend/src/components/ds/ContentCard.tsx +34 -0
  79. package/templates/assistkick-product-system/packages/frontend/src/components/ds/IconButton.tsx +74 -0
  80. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCard.tsx +103 -87
  81. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KanbanCardShowcase.tsx +9 -188
  82. package/templates/assistkick-product-system/packages/frontend/src/components/ds/Kbd.tsx +11 -0
  83. package/templates/assistkick-product-system/packages/frontend/src/components/ds/KindBadge.tsx +21 -0
  84. package/templates/assistkick-product-system/packages/frontend/src/components/ds/NavBarSidekick.tsx +81 -37
  85. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SidePanelShowcase.tsx +370 -0
  86. package/templates/assistkick-product-system/packages/frontend/src/components/ds/SideSheet.tsx +64 -0
  87. package/templates/assistkick-product-system/packages/frontend/src/components/ds/StatusDot.tsx +18 -0
  88. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCardPositionNode.tsx +36 -0
  89. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/CheckCycleCountNode.tsx +60 -0
  90. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/EndNode.tsx +42 -0
  91. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/GroupNode.tsx +189 -0
  92. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/NodePalette.tsx +123 -0
  93. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/RunAgentNode.tsx +51 -0
  94. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/SetCardMetadataNode.tsx +53 -0
  95. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/StartNode.tsx +18 -0
  96. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/TransitionCardNode.tsx +59 -0
  97. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowCanvas.tsx +335 -0
  98. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/WorkflowMonitorModal.tsx +634 -0
  99. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/autoLayout.ts +103 -0
  100. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/edgeColors.ts +35 -0
  101. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/monitor_nodes.tsx +208 -0
  102. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.test.ts +119 -0
  103. package/templates/assistkick-product-system/packages/frontend/src/components/workflow/workflow_types.ts +107 -0
  104. package/templates/assistkick-product-system/packages/frontend/src/constants/graph.ts +13 -11
  105. package/templates/assistkick-product-system/packages/frontend/src/hooks/useAutoSave.ts +75 -0
  106. package/templates/assistkick-product-system/packages/frontend/src/hooks/useToast.tsx +16 -3
  107. package/templates/assistkick-product-system/packages/frontend/src/pages/accept_invitation_page.tsx +30 -27
  108. package/templates/assistkick-product-system/packages/frontend/src/pages/forgot_password_page.tsx +18 -15
  109. package/templates/assistkick-product-system/packages/frontend/src/pages/register_page.tsx +21 -18
  110. package/templates/assistkick-product-system/packages/frontend/src/pages/reset_password_page.tsx +28 -25
  111. package/templates/assistkick-product-system/packages/frontend/src/routes/AgentsRoute.tsx +6 -0
  112. package/templates/assistkick-product-system/packages/frontend/src/routes/CoherenceRoute.tsx +1 -1
  113. package/templates/assistkick-product-system/packages/frontend/src/routes/DashboardLayout.tsx +2 -2
  114. package/templates/assistkick-product-system/packages/frontend/src/routes/FilesRoute.tsx +13 -0
  115. package/templates/assistkick-product-system/packages/frontend/src/routes/GraphRoute.tsx +2 -2
  116. package/templates/assistkick-product-system/packages/frontend/src/routes/VideographyRoute.tsx +13 -0
  117. package/templates/assistkick-product-system/packages/frontend/src/routes/WorkflowsRoute.tsx +6 -0
  118. package/templates/assistkick-product-system/packages/frontend/src/stores/useProjectStore.ts +6 -3
  119. package/templates/assistkick-product-system/packages/frontend/src/stores/useSidePanelStore.ts +4 -4
  120. package/templates/assistkick-product-system/packages/frontend/src/styles/index.css +275 -3535
  121. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.test.ts +167 -0
  122. package/templates/assistkick-product-system/packages/frontend/src/utils/auto_save_service.ts +101 -0
  123. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.test.ts +42 -0
  124. package/templates/assistkick-product-system/packages/frontend/src/utils/composition_matcher.ts +17 -0
  125. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.test.ts +145 -0
  126. package/templates/assistkick-product-system/packages/frontend/src/utils/file_utils.ts +42 -0
  127. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.test.ts +4 -10
  128. package/templates/assistkick-product-system/packages/frontend/src/utils/task_status.ts +19 -1
  129. package/templates/assistkick-product-system/packages/frontend/vite.config.ts +5 -0
  130. package/templates/assistkick-product-system/packages/shared/db/local.db +0 -0
  131. package/templates/assistkick-product-system/packages/shared/db/migrations/0004_tidy_matthew_murdock.sql +9 -0
  132. package/templates/assistkick-product-system/packages/shared/db/migrations/0005_mysterious_falcon.sql +692 -0
  133. package/templates/assistkick-product-system/packages/shared/db/migrations/0006_next_venom.sql +9 -0
  134. package/templates/assistkick-product-system/packages/shared/db/migrations/0007_deep_barracuda.sql +39 -0
  135. package/templates/assistkick-product-system/packages/shared/db/migrations/0008_puzzling_hannibal_king.sql +1 -0
  136. package/templates/assistkick-product-system/packages/shared/db/migrations/0009_amused_beast.sql +8 -0
  137. package/templates/assistkick-product-system/packages/shared/db/migrations/0010_spotty_moira_mactaggert.sql +9 -0
  138. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_goofy_snowbird.sql +3 -0
  139. package/templates/assistkick-product-system/packages/shared/db/migrations/0011_supreme_doctor_octopus.sql +3 -0
  140. package/templates/assistkick-product-system/packages/shared/db/migrations/0013_reflective_prowler.sql +15 -0
  141. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0004_snapshot.json +921 -0
  142. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0005_snapshot.json +1042 -0
  143. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0006_snapshot.json +1101 -0
  144. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0007_snapshot.json +1336 -0
  145. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0008_snapshot.json +1275 -0
  146. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0009_snapshot.json +1327 -0
  147. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0010_snapshot.json +1393 -0
  148. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0011_snapshot.json +1436 -0
  149. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/0013_snapshot.json +1538 -0
  150. package/templates/assistkick-product-system/packages/shared/db/migrations/meta/_journal.json +70 -0
  151. package/templates/assistkick-product-system/packages/shared/db/schema.ts +113 -0
  152. package/templates/assistkick-product-system/packages/shared/lib/claude-service.ts +32 -7
  153. package/templates/assistkick-product-system/packages/shared/lib/constants.ts +9 -0
  154. package/templates/assistkick-product-system/packages/shared/lib/git_workflow.ts +12 -4
  155. package/templates/assistkick-product-system/packages/shared/lib/graph.ts +5 -0
  156. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.test.ts +1753 -0
  157. package/templates/assistkick-product-system/packages/shared/lib/workflow_engine.ts +1281 -0
  158. package/templates/assistkick-product-system/packages/shared/lib/workflow_orchestrator.ts +211 -0
  159. package/templates/assistkick-product-system/packages/shared/tools/add_node.test.ts +43 -0
  160. package/templates/assistkick-product-system/packages/shared/tools/add_node.ts +13 -2
  161. package/templates/assistkick-product-system/packages/shared/tools/get_kanban.ts +1 -1
  162. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.test.ts +226 -0
  163. package/templates/assistkick-product-system/packages/shared/tools/migrate_epics.ts +251 -0
  164. package/templates/assistkick-product-system/packages/shared/tools/update_node.ts +2 -2
  165. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.test.ts +10 -0
  166. package/templates/assistkick-product-system/packages/shared/utils/hello_workflow.ts +6 -0
  167. package/templates/assistkick-product-system/packages/video/Root.tsx +85 -0
  168. package/templates/assistkick-product-system/packages/video/components/email_scene.tsx +231 -0
  169. package/templates/assistkick-product-system/packages/video/components/outro_scene.tsx +153 -0
  170. package/templates/assistkick-product-system/packages/video/components/part_divider.tsx +90 -0
  171. package/templates/assistkick-product-system/packages/video/components/scene.tsx +226 -0
  172. package/templates/assistkick-product-system/packages/video/components/theme.ts +22 -0
  173. package/templates/assistkick-product-system/packages/video/components/title_scene.tsx +169 -0
  174. package/templates/assistkick-product-system/packages/video/components/video_split_layout.tsx +84 -0
  175. package/templates/assistkick-product-system/packages/video/compositions/.gitkeep +0 -0
  176. package/templates/assistkick-product-system/packages/video/index.ts +4 -0
  177. package/templates/assistkick-product-system/packages/video/package.json +28 -0
  178. package/templates/assistkick-product-system/packages/video/remotion.config.ts +11 -0
  179. package/templates/assistkick-product-system/packages/video/scripts/process_script.test.ts +326 -0
  180. package/templates/assistkick-product-system/packages/video/scripts/process_script.ts +630 -0
  181. package/templates/assistkick-product-system/packages/video/style.css +1 -0
  182. package/templates/assistkick-product-system/packages/video/tsconfig.json +18 -0
  183. package/templates/assistkick-product-system/tests/graph_legend.test.ts +2 -1
  184. package/templates/assistkick-product-system/tests/video_render_service.test.ts +179 -0
  185. package/templates/assistkick-product-system/tests/web_terminal.test.ts +219 -455
  186. package/templates/assistkick-product-system/tests/workflow_integration.test.ts +341 -0
  187. package/templates/skills/assistkick-developer/SKILL.md +3 -0
  188. package/templates/skills/assistkick-developer/references/react_development_guidelines.md +225 -0
  189. package/templates/skills/product-system/graph.json +1890 -0
  190. package/templates/skills/product-system/kanban.json +304 -0
  191. package/templates/skills/product-system/nodes/comp_001.md +56 -0
  192. package/templates/skills/product-system/nodes/comp_002.md +57 -0
  193. package/templates/skills/product-system/nodes/data_001.md +51 -0
  194. package/templates/skills/product-system/nodes/data_002.md +40 -0
  195. package/templates/skills/product-system/nodes/data_004.md +38 -0
  196. package/templates/skills/product-system/nodes/dec_001.md +34 -0
  197. package/templates/skills/product-system/nodes/dec_016.md +32 -0
  198. package/templates/skills/product-system/nodes/feat_008.md +30 -0
  199. package/templates/skills/video-composition-agent/SKILL.md +232 -0
  200. package/templates/skills/video-script-writer/SKILL.md +136 -0
@@ -1,63 +1,61 @@
1
1
  /**
2
2
  * PTY Session Manager — manages named, project-scoped terminal sessions.
3
- * Implements dec_029 (Command Prefix Whitelist) and dec_030 (Server-Side PTY Session Persistence).
3
+ * Sessions are persisted in the database and survive server restarts.
4
+ * PTY processes are ephemeral — spawned on demand when a user connects.
4
5
  *
5
- * Each session has a unique ID, a human-readable name (project name + creation timestamp),
6
- * and is permanently bound to a project. Sessions persist across WebSocket disconnects.
6
+ * New sessions launch claude with `--session-id <uuid>` to set a known ID.
7
+ * Resumed sessions (after restart) launch claude with `--resume <uuid>`.
7
8
  */
8
9
 
9
10
  import type { IPty } from 'node-pty';
10
- import { randomBytes } from 'node:crypto';
11
+ import { randomBytes, randomUUID } from 'node:crypto';
12
+ import { desc, eq } from 'drizzle-orm';
13
+ import { terminalSessions } from '@assistkick/shared/db/schema.js';
11
14
 
12
15
  const OUTPUT_BUFFER_MAX = 50_000;
13
-
14
- const ALLOWED_COMMAND_PREFIXES = [
15
- 'claude',
16
- ] as const;
17
-
18
- const PROMPT = '\x1b[32m$\x1b[0m ';
19
- const WELCOME_MSG = `\x1b[90mRestricted terminal — allowed commands: ${ALLOWED_COMMAND_PREFIXES.join(', ')}\x1b[0m\r\n`;
16
+ const IDLE_TIMEOUT_MS = 1_800_000; // 30 minutes
20
17
 
21
18
  interface PtySessionManagerDeps {
22
19
  spawn: (shell: string, args: string[], options: Record<string, unknown>) => IPty;
23
20
  log: (tag: string, ...args: unknown[]) => void;
24
21
  projectRoot: string;
22
+ getDb: () => any;
25
23
  }
26
24
 
27
- interface PtySession {
28
- id: string;
29
- name: string;
30
- projectId: string;
31
- projectName: string;
32
- pty: IPty | null;
33
- state: 'idle' | 'running';
34
- cols: number;
35
- rows: number;
25
+ /** In-memory state for a live PTY process. */
26
+ interface LivePty {
27
+ pty: IPty;
36
28
  outputBuffer: string;
37
- inputBuffer: string;
38
29
  listeners: Set<(data: string) => void>;
39
- createdAt: Date;
30
+ cols: number;
31
+ rows: number;
40
32
  }
41
33
 
42
34
  export interface SessionInfo {
43
35
  id: string;
36
+ claudeSessionId: string;
44
37
  name: string;
45
38
  projectId: string;
46
39
  projectName: string;
47
- state: 'idle' | 'running';
40
+ state: 'suspended' | 'running';
48
41
  createdAt: string;
42
+ lastUsedAt: string;
49
43
  }
50
44
 
51
45
  export class PtySessionManager {
52
- private readonly sessions = new Map<string, PtySession>();
46
+ /** Live PTY processes keyed by session ID. Only exists while PTY is running. */
47
+ private readonly live = new Map<string, LivePty>();
48
+ private readonly disconnectTimers = new Map<string, NodeJS.Timeout>();
53
49
  private readonly spawn: PtySessionManagerDeps['spawn'];
54
50
  private readonly log: PtySessionManagerDeps['log'];
55
51
  private readonly projectRoot: string;
52
+ private readonly getDb: PtySessionManagerDeps['getDb'];
56
53
 
57
- constructor({ spawn, log, projectRoot }: PtySessionManagerDeps) {
54
+ constructor({ spawn, log, projectRoot, getDb }: PtySessionManagerDeps) {
58
55
  this.spawn = spawn;
59
56
  this.log = log;
60
57
  this.projectRoot = projectRoot;
58
+ this.getDb = getDb;
61
59
  }
62
60
 
63
61
  generateId = (): string => {
@@ -74,215 +72,256 @@ export class PtySessionManager {
74
72
  return `${projectName} - ${dd}/${mm}/${yy} - ${hh}:${min}`;
75
73
  };
76
74
 
77
- validateCommand = (input: string): { valid: boolean; error?: string } => {
78
- const trimmed = input.trim();
79
- if (!trimmed) {
80
- return { valid: false, error: 'Empty command' };
81
- }
82
-
83
- const command = trimmed.split(/\s+/)[0];
84
- const isAllowed = ALLOWED_COMMAND_PREFIXES.some(prefix => command === prefix);
85
-
86
- if (!isAllowed) {
87
- return {
88
- valid: false,
89
- error: `Command "${command}" is not allowed. Allowed commands: ${ALLOWED_COMMAND_PREFIXES.join(', ')}`,
90
- };
91
- }
92
-
93
- return { valid: true };
94
- };
95
-
96
- listSessions = (): SessionInfo[] => {
97
- return Array.from(this.sessions.values()).map(s => ({
98
- id: s.id,
99
- name: s.name,
100
- projectId: s.projectId,
101
- projectName: s.projectName,
102
- state: s.state,
103
- createdAt: s.createdAt.toISOString(),
75
+ /** List all sessions from the database, annotated with live/suspended state. */
76
+ listSessions = async (): Promise<SessionInfo[]> => {
77
+ const db = this.getDb();
78
+ const rows = await db.select().from(terminalSessions).orderBy(desc(terminalSessions.createdAt));
79
+ return rows.map((r: any) => ({
80
+ id: r.id,
81
+ claudeSessionId: r.claudeSessionId,
82
+ name: r.name,
83
+ projectId: r.projectId,
84
+ projectName: r.projectName,
85
+ state: this.live.has(r.id) ? 'running' as const : 'suspended' as const,
86
+ createdAt: r.createdAt,
87
+ lastUsedAt: r.lastUsedAt,
104
88
  }));
105
89
  };
106
90
 
107
- getSession = (sessionId: string): PtySession | undefined => {
108
- return this.sessions.get(sessionId);
91
+ /** Check whether a session exists (in DB). */
92
+ getSession = async (sessionId: string): Promise<SessionInfo | undefined> => {
93
+ const db = this.getDb();
94
+ const rows = await db.select().from(terminalSessions).where(eq(terminalSessions.id, sessionId));
95
+ if (rows.length === 0) return undefined;
96
+ const r = rows[0];
97
+ return {
98
+ id: r.id,
99
+ claudeSessionId: r.claudeSessionId,
100
+ name: r.name,
101
+ projectId: r.projectId,
102
+ projectName: r.projectName,
103
+ state: this.live.has(r.id) ? 'running' as const : 'suspended' as const,
104
+ createdAt: r.createdAt,
105
+ lastUsedAt: r.lastUsedAt,
106
+ };
109
107
  };
110
108
 
111
- createSession = (projectId: string, projectName: string, cols: number, rows: number): PtySession => {
109
+ /** Create a new session persists to DB and spawns claude with --session-id. */
110
+ createSession = async (projectId: string, projectName: string, cols: number, rows: number): Promise<SessionInfo> => {
112
111
  const id = this.generateId();
112
+ const claudeSessionId = randomUUID();
113
113
  const createdAt = new Date();
114
114
  const name = this.formatSessionName(projectName, createdAt);
115
+ const now = createdAt.toISOString();
115
116
 
116
- const session: PtySession = {
117
+ // Persist to DB
118
+ const db = this.getDb();
119
+ await db.insert(terminalSessions).values({
117
120
  id,
118
- name,
121
+ claudeSessionId,
119
122
  projectId,
120
123
  projectName,
121
- pty: null,
122
- state: 'idle',
123
- cols,
124
- rows,
125
- outputBuffer: '',
126
- inputBuffer: '',
127
- listeners: new Set(),
128
- createdAt,
129
- };
124
+ name,
125
+ createdAt: now,
126
+ lastUsedAt: now,
127
+ });
130
128
 
131
- this.sessions.set(id, session);
132
- this.log('PTY', `Created session "${name}" (${id}) for project ${projectId}`);
129
+ this.log('PTY', `Created session "${name}" (${id}) for project ${projectId}, claude session ${claudeSessionId}`);
133
130
 
134
- // Show welcome message then auto-launch claude with project context
135
- this.emitOutput(session, WELCOME_MSG);
136
- this.autoLaunchClaude(session);
131
+ // Spawn claude with --session-id
132
+ this.spawnClaude(id, claudeSessionId, projectId, cols, rows, false);
137
133
 
138
- return session;
134
+ return {
135
+ id,
136
+ claudeSessionId,
137
+ name,
138
+ projectId,
139
+ projectName,
140
+ state: 'running',
141
+ createdAt: now,
142
+ lastUsedAt: now,
143
+ };
144
+ };
145
+
146
+ /**
147
+ * Ensure the PTY is running for a session. If suspended (no PTY), resume claude.
148
+ * Returns true if PTY is (now) running; false if session not found.
149
+ */
150
+ ensureRunning = async (sessionId: string, cols: number, rows: number): Promise<boolean> => {
151
+ if (this.live.has(sessionId)) return true;
152
+
153
+ const session = await this.getSession(sessionId);
154
+ if (!session) return false;
155
+
156
+ // Update last used timestamp
157
+ const db = this.getDb();
158
+ await db.update(terminalSessions)
159
+ .set({ lastUsedAt: new Date().toISOString() })
160
+ .where(eq(terminalSessions.id, sessionId));
161
+
162
+ // Resume claude
163
+ this.spawnClaude(sessionId, session.claudeSessionId, session.projectId, cols, rows, true);
164
+ return true;
139
165
  };
140
166
 
141
167
  addListener = (sessionId: string, listener: (data: string) => void): void => {
142
- const session = this.sessions.get(sessionId);
143
- if (session) {
144
- session.listeners.add(listener);
168
+ const entry = this.live.get(sessionId);
169
+ if (entry) {
170
+ const pendingTimer = this.disconnectTimers.get(sessionId);
171
+ if (pendingTimer) {
172
+ clearTimeout(pendingTimer);
173
+ this.disconnectTimers.delete(sessionId);
174
+ this.log('PTY', `Cancelled idle timer for session ${sessionId} — client reconnected`);
175
+ }
176
+ entry.listeners.add(listener);
145
177
  }
146
178
  };
147
179
 
148
180
  removeListener = (sessionId: string, listener: (data: string) => void): void => {
149
- const session = this.sessions.get(sessionId);
150
- if (session) {
151
- session.listeners.delete(listener);
181
+ const entry = this.live.get(sessionId);
182
+ if (entry) {
183
+ entry.listeners.delete(listener);
184
+ if (entry.listeners.size === 0 && !this.disconnectTimers.has(sessionId)) {
185
+ this.log('PTY', `All listeners disconnected from session ${sessionId} — starting ${IDLE_TIMEOUT_MS / 60_000}min idle timer`);
186
+ const timer = setTimeout(() => {
187
+ this.disconnectTimers.delete(sessionId);
188
+ const current = this.live.get(sessionId);
189
+ if (current && current.listeners.size === 0) {
190
+ this.log('PTY', `Idle timer fired for session ${sessionId} — killing orphan PTY`);
191
+ current.pty.kill();
192
+ this.live.delete(sessionId);
193
+ }
194
+ }, IDLE_TIMEOUT_MS);
195
+ this.disconnectTimers.set(sessionId, timer);
196
+ }
152
197
  }
153
198
  };
154
199
 
155
200
  writeToSession = (sessionId: string, data: string): void => {
156
- const session = this.sessions.get(sessionId);
157
- if (!session) return;
158
-
159
- if (session.state === 'running' && session.pty) {
160
- // Forward raw input to the running command
161
- session.pty.write(data);
162
- } else if (session.state === 'idle') {
163
- // Handle command-line input
164
- this.handleIdleInput(session, data);
201
+ const entry = this.live.get(sessionId);
202
+ if (entry) {
203
+ entry.pty.write(data);
165
204
  }
166
205
  };
167
206
 
168
207
  resizeSession = (sessionId: string, cols: number, rows: number): void => {
169
- const session = this.sessions.get(sessionId);
170
- if (session) {
171
- session.cols = cols;
172
- session.rows = rows;
173
- if (session.pty) {
174
- session.pty.resize(cols, rows);
175
- }
208
+ const entry = this.live.get(sessionId);
209
+ if (entry) {
210
+ entry.cols = cols;
211
+ entry.rows = rows;
212
+ entry.pty.resize(cols, rows);
176
213
  }
177
214
  };
178
215
 
179
- destroySession = (sessionId: string): void => {
180
- const session = this.sessions.get(sessionId);
181
- if (session) {
182
- if (session.pty) {
183
- session.pty.kill();
184
- }
185
- this.sessions.delete(sessionId);
186
- this.log('PTY', `Destroyed session "${session.name}" (${sessionId})`);
216
+ /** Destroy a session kills PTY and removes from DB permanently. */
217
+ destroySession = async (sessionId: string): Promise<void> => {
218
+ // Clear any pending idle timer
219
+ const pendingTimer = this.disconnectTimers.get(sessionId);
220
+ if (pendingTimer) {
221
+ clearTimeout(pendingTimer);
222
+ this.disconnectTimers.delete(sessionId);
187
223
  }
224
+
225
+ // Kill live PTY if running
226
+ const entry = this.live.get(sessionId);
227
+ if (entry) {
228
+ entry.pty.kill();
229
+ this.live.delete(sessionId);
230
+ }
231
+
232
+ // Remove from DB
233
+ const db = this.getDb();
234
+ await db.delete(terminalSessions).where(eq(terminalSessions.id, sessionId));
235
+ this.log('PTY', `Destroyed session ${sessionId}`);
188
236
  };
189
237
 
190
- destroyAll = (): void => {
191
- for (const [sessionId] of this.sessions) {
192
- this.destroySession(sessionId);
238
+ /** Kill all live PTY processes (on server shutdown). Does NOT remove DB records. */
239
+ destroyAllPty = (): void => {
240
+ for (const timer of this.disconnectTimers.values()) {
241
+ clearTimeout(timer);
242
+ }
243
+ this.disconnectTimers.clear();
244
+ for (const [id, entry] of this.live) {
245
+ entry.pty.kill();
246
+ this.log('PTY', `Killed PTY for session ${id} (shutdown)`);
193
247
  }
248
+ this.live.clear();
194
249
  };
195
250
 
196
- getBufferedOutput = (sessionId: string): string => {
197
- const session = this.sessions.get(sessionId);
198
- return session?.outputBuffer ?? '';
251
+ getDisconnectTimerCount = (): number => {
252
+ return this.disconnectTimers.size;
199
253
  };
200
254
 
201
- private handleIdleInput = (session: PtySession, data: string): void => {
202
- for (const char of data) {
203
- if (char === '\r' || char === '\n') {
204
- // Enter pressed — validate and execute
205
- this.emitOutput(session, '\r\n');
206
- const command = session.inputBuffer.trim();
207
- session.inputBuffer = '';
208
-
209
- if (!command) {
210
- this.emitOutput(session, PROMPT);
211
- return;
212
- }
213
-
214
- const validation = this.validateCommand(command);
215
- if (!validation.valid) {
216
- this.emitOutput(session, `\x1b[31m${validation.error}\x1b[0m\r\n`);
217
- this.emitOutput(session, PROMPT);
218
- return;
219
- }
220
-
221
- this.spawnCommand(session, command);
222
- } else if (char === '\x7f' || char === '\b') {
223
- // Backspace
224
- if (session.inputBuffer.length > 0) {
225
- session.inputBuffer = session.inputBuffer.slice(0, -1);
226
- this.emitOutput(session, '\b \b');
227
- }
228
- } else if (char === '\x03') {
229
- // Ctrl+C — clear input
230
- session.inputBuffer = '';
231
- this.emitOutput(session, '^C\r\n');
232
- this.emitOutput(session, PROMPT);
233
- } else if (char >= ' ') {
234
- // Printable character — echo and buffer
235
- session.inputBuffer += char;
236
- this.emitOutput(session, char);
237
- }
238
- }
255
+ hasDisconnectTimer = (sessionId: string): boolean => {
256
+ return this.disconnectTimers.has(sessionId);
239
257
  };
240
258
 
241
- private autoLaunchClaude = (session: PtySession): void => {
242
- const projectContext = `We are working on project-id ${session.projectId}`;
243
- const args = ['--dangerously-skip-permissions', '--append-system-prompt', projectContext];
259
+ getBufferedOutput = (sessionId: string): string => {
260
+ return this.live.get(sessionId)?.outputBuffer ?? '';
261
+ };
244
262
 
245
- this.log('PTY', `Auto-launching claude for session ${session.id} (project ${session.projectId})`);
263
+ isLive = (sessionId: string): boolean => {
264
+ return this.live.has(sessionId);
265
+ };
266
+
267
+ // --- Private helpers ---
268
+
269
+ private spawnClaude = (
270
+ sessionId: string,
271
+ claudeSessionId: string,
272
+ projectId: string,
273
+ cols: number,
274
+ rows: number,
275
+ resume: boolean,
276
+ ): void => {
277
+ const projectContext = `We are working on project-id ${projectId}`;
278
+ const args: string[] = ['--dangerously-skip-permissions', '--append-system-prompt', projectContext];
279
+
280
+ if (resume) {
281
+ args.push('--resume', claudeSessionId);
282
+ this.log('PTY', `Resuming claude session ${claudeSessionId} for ${sessionId}`);
283
+ } else {
284
+ args.push('--session-id', claudeSessionId);
285
+ this.log('PTY', `Launching new claude session ${claudeSessionId} for ${sessionId}`);
286
+ }
246
287
 
247
288
  let spawnedPty: IPty;
248
289
  try {
249
290
  spawnedPty = this.spawn('claude', args, {
250
291
  name: 'xterm-256color',
251
- cols: session.cols,
252
- rows: session.rows,
292
+ cols,
293
+ rows,
253
294
  cwd: this.projectRoot,
254
295
  env: this.buildEnv(),
255
296
  });
256
297
  } catch (err) {
257
298
  const msg = err instanceof Error ? err.message : String(err);
258
- this.emitOutput(session, `\x1b[31mFailed to auto-launch claude: ${msg}\x1b[0m\r\n`);
259
- this.log('PTY', `Auto-launch failed for session ${session.id}: ${msg}`);
260
- this.emitOutput(session, PROMPT);
299
+ this.log('PTY', `Failed to spawn claude for session ${sessionId}: ${msg}`);
261
300
  return;
262
301
  }
263
302
 
264
- session.pty = spawnedPty;
265
- session.state = 'running';
303
+ const entry: LivePty = {
304
+ pty: spawnedPty,
305
+ outputBuffer: '',
306
+ listeners: new Set(),
307
+ cols,
308
+ rows,
309
+ };
310
+ this.live.set(sessionId, entry);
266
311
 
267
312
  spawnedPty.onData((data: string) => {
268
- this.emitOutput(session, data);
313
+ this.emitOutput(entry, data);
269
314
  });
270
315
 
271
316
  spawnedPty.onExit(({ exitCode }) => {
272
- this.log('PTY', `Auto-launched claude exited with code ${exitCode} for session ${session.id}`);
273
- session.pty = null;
274
- session.state = 'idle';
275
- this.emitOutput(session, '\r\n');
276
- this.emitOutput(session, PROMPT);
317
+ this.log('PTY', `Claude exited with code ${exitCode} for session ${sessionId}`);
318
+ this.live.delete(sessionId);
277
319
  });
278
320
  };
279
321
 
280
322
  private buildEnv = (): Record<string, string> => {
281
323
  const env = { ...process.env } as Record<string, string>;
282
324
  const home = env.HOME || '/root';
283
- // Ensure common user-local binary directories are on PATH so that
284
- // tools installed under the user profile (e.g. claude via npm -g)
285
- // are found even when the server wasn't started from a login shell.
286
325
  const extraPaths = [
287
326
  `${home}/.local/bin`,
288
327
  `${home}/.npm-global/bin`,
@@ -298,56 +337,12 @@ export class PtySessionManager {
298
337
  return env;
299
338
  };
300
339
 
301
- private spawnCommand = (session: PtySession, command: string): void => {
302
- const parts = command.trim().split(/\s+/);
303
- const cmd = parts[0];
304
- const args = parts.slice(1);
305
-
306
- this.log('PTY', `Executing validated command "${command}" for session ${session.id}`);
307
-
308
- let spawnedPty: IPty;
309
- try {
310
- spawnedPty = this.spawn(cmd, args, {
311
- name: 'xterm-256color',
312
- cols: session.cols,
313
- rows: session.rows,
314
- cwd: this.projectRoot,
315
- env: this.buildEnv(),
316
- });
317
- } catch (err) {
318
- const msg = err instanceof Error ? err.message : String(err);
319
- const env = this.buildEnv();
320
- this.emitOutput(session, `\x1b[31mFailed to execute: ${msg}\x1b[0m\r\n`);
321
- this.emitOutput(session, `\x1b[90mPATH: ${env.PATH}\x1b[0m\r\n`);
322
- this.emitOutput(session, `\x1b[90mHOME: ${env.HOME || '(unset)'}\x1b[0m\r\n`);
323
- this.emitOutput(session, `\x1b[90mSHELL: ${env.SHELL || '(unset)'}\x1b[0m\r\n`);
324
- this.log('PTY', `Spawn failed for "${command}": ${msg} | PATH=${env.PATH}`);
325
- this.emitOutput(session, PROMPT);
326
- return;
327
- }
328
-
329
- session.pty = spawnedPty;
330
- session.state = 'running';
331
-
332
- spawnedPty.onData((data: string) => {
333
- this.emitOutput(session, data);
334
- });
335
-
336
- spawnedPty.onExit(({ exitCode }) => {
337
- this.log('PTY', `Command exited with code ${exitCode} for session ${session.id}`);
338
- session.pty = null;
339
- session.state = 'idle';
340
- this.emitOutput(session, '\r\n');
341
- this.emitOutput(session, PROMPT);
342
- });
343
- };
344
-
345
- private emitOutput = (session: PtySession, data: string): void => {
346
- session.outputBuffer += data;
347
- if (session.outputBuffer.length > OUTPUT_BUFFER_MAX) {
348
- session.outputBuffer = session.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
340
+ private emitOutput = (entry: LivePty, data: string): void => {
341
+ entry.outputBuffer += data;
342
+ if (entry.outputBuffer.length > OUTPUT_BUFFER_MAX) {
343
+ entry.outputBuffer = entry.outputBuffer.slice(-OUTPUT_BUFFER_MAX);
349
344
  }
350
- for (const listener of session.listeners) {
345
+ for (const listener of entry.listeners) {
351
346
  listener(data);
352
347
  }
353
348
  };