@herdctl/core 1.3.1 → 2.0.1

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 (187) hide show
  1. package/dist/config/__tests__/agent.test.js +12 -12
  2. package/dist/config/__tests__/agent.test.js.map +1 -1
  3. package/dist/config/__tests__/loader.test.js +201 -4
  4. package/dist/config/__tests__/loader.test.js.map +1 -1
  5. package/dist/config/__tests__/merge.test.js +29 -4
  6. package/dist/config/__tests__/merge.test.js.map +1 -1
  7. package/dist/config/__tests__/parser.test.js +13 -13
  8. package/dist/config/__tests__/parser.test.js.map +1 -1
  9. package/dist/config/__tests__/schema.test.js +10 -10
  10. package/dist/config/__tests__/schema.test.js.map +1 -1
  11. package/dist/config/index.d.ts +1 -1
  12. package/dist/config/index.d.ts.map +1 -1
  13. package/dist/config/index.js +2 -2
  14. package/dist/config/index.js.map +1 -1
  15. package/dist/config/loader.d.ts.map +1 -1
  16. package/dist/config/loader.js +71 -0
  17. package/dist/config/loader.js.map +1 -1
  18. package/dist/config/merge.d.ts +4 -1
  19. package/dist/config/merge.d.ts.map +1 -1
  20. package/dist/config/merge.js +16 -0
  21. package/dist/config/merge.js.map +1 -1
  22. package/dist/config/schema.d.ts +906 -89
  23. package/dist/config/schema.d.ts.map +1 -1
  24. package/dist/config/schema.js +109 -7
  25. package/dist/config/schema.js.map +1 -1
  26. package/dist/fleet-manager/__tests__/coverage.test.js +25 -24
  27. package/dist/fleet-manager/__tests__/coverage.test.js.map +1 -1
  28. package/dist/fleet-manager/__tests__/discord-manager.test.js +9 -2
  29. package/dist/fleet-manager/__tests__/discord-manager.test.js.map +1 -1
  30. package/dist/fleet-manager/__tests__/integration.test.js +27 -0
  31. package/dist/fleet-manager/__tests__/integration.test.js.map +1 -1
  32. package/dist/fleet-manager/__tests__/job-control.test.js +66 -0
  33. package/dist/fleet-manager/__tests__/job-control.test.js.map +1 -1
  34. package/dist/fleet-manager/__tests__/status-queries.test.js +12 -11
  35. package/dist/fleet-manager/__tests__/status-queries.test.js.map +1 -1
  36. package/dist/fleet-manager/config-reload.js +9 -9
  37. package/dist/fleet-manager/config-reload.js.map +1 -1
  38. package/dist/fleet-manager/discord-manager.d.ts.map +1 -1
  39. package/dist/fleet-manager/discord-manager.js +27 -4
  40. package/dist/fleet-manager/discord-manager.js.map +1 -1
  41. package/dist/fleet-manager/fleet-manager.d.ts +11 -0
  42. package/dist/fleet-manager/fleet-manager.d.ts.map +1 -1
  43. package/dist/fleet-manager/fleet-manager.js +27 -0
  44. package/dist/fleet-manager/fleet-manager.js.map +1 -1
  45. package/dist/fleet-manager/job-control.d.ts +1 -1
  46. package/dist/fleet-manager/job-control.d.ts.map +1 -1
  47. package/dist/fleet-manager/job-control.js +36 -14
  48. package/dist/fleet-manager/job-control.js.map +1 -1
  49. package/dist/fleet-manager/schedule-executor.d.ts +1 -1
  50. package/dist/fleet-manager/schedule-executor.d.ts.map +1 -1
  51. package/dist/fleet-manager/schedule-executor.js +17 -17
  52. package/dist/fleet-manager/schedule-executor.js.map +1 -1
  53. package/dist/fleet-manager/status-queries.js +7 -7
  54. package/dist/fleet-manager/status-queries.js.map +1 -1
  55. package/dist/fleet-manager/types.d.ts +10 -2
  56. package/dist/fleet-manager/types.d.ts.map +1 -1
  57. package/dist/fleet-manager/working-directory-helper.d.ts +29 -0
  58. package/dist/fleet-manager/working-directory-helper.d.ts.map +1 -0
  59. package/dist/fleet-manager/working-directory-helper.js +36 -0
  60. package/dist/fleet-manager/working-directory-helper.js.map +1 -0
  61. package/dist/hooks/__tests__/discord-runner.test.js +16 -16
  62. package/dist/hooks/__tests__/discord-runner.test.js.map +1 -1
  63. package/dist/hooks/runners/discord.d.ts.map +1 -1
  64. package/dist/hooks/runners/discord.js +15 -12
  65. package/dist/hooks/runners/discord.js.map +1 -1
  66. package/dist/runner/__tests__/job-executor.test.js +461 -126
  67. package/dist/runner/__tests__/job-executor.test.js.map +1 -1
  68. package/dist/runner/__tests__/message-processor.test.js +12 -35
  69. package/dist/runner/__tests__/message-processor.test.js.map +1 -1
  70. package/dist/runner/__tests__/sdk-adapter.test.js +137 -2
  71. package/dist/runner/__tests__/sdk-adapter.test.js.map +1 -1
  72. package/dist/runner/index.d.ts +2 -0
  73. package/dist/runner/index.d.ts.map +1 -1
  74. package/dist/runner/index.js +1 -0
  75. package/dist/runner/index.js.map +1 -1
  76. package/dist/runner/job-executor.d.ts +12 -8
  77. package/dist/runner/job-executor.d.ts.map +1 -1
  78. package/dist/runner/job-executor.js +280 -133
  79. package/dist/runner/job-executor.js.map +1 -1
  80. package/dist/runner/message-processor.d.ts +5 -2
  81. package/dist/runner/message-processor.d.ts.map +1 -1
  82. package/dist/runner/message-processor.js +9 -18
  83. package/dist/runner/message-processor.js.map +1 -1
  84. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts +2 -0
  85. package/dist/runner/runtime/__tests__/cli-session-path.test.d.ts.map +1 -0
  86. package/dist/runner/runtime/__tests__/cli-session-path.test.js +150 -0
  87. package/dist/runner/runtime/__tests__/cli-session-path.test.js.map +1 -0
  88. package/dist/runner/runtime/__tests__/docker-config.test.d.ts +2 -0
  89. package/dist/runner/runtime/__tests__/docker-config.test.d.ts.map +1 -0
  90. package/dist/runner/runtime/__tests__/docker-config.test.js +352 -0
  91. package/dist/runner/runtime/__tests__/docker-config.test.js.map +1 -0
  92. package/dist/runner/runtime/__tests__/docker-security.test.d.ts +2 -0
  93. package/dist/runner/runtime/__tests__/docker-security.test.d.ts.map +1 -0
  94. package/dist/runner/runtime/__tests__/docker-security.test.js +384 -0
  95. package/dist/runner/runtime/__tests__/docker-security.test.js.map +1 -0
  96. package/dist/runner/runtime/__tests__/factory.test.d.ts +2 -0
  97. package/dist/runner/runtime/__tests__/factory.test.d.ts.map +1 -0
  98. package/dist/runner/runtime/__tests__/factory.test.js +149 -0
  99. package/dist/runner/runtime/__tests__/factory.test.js.map +1 -0
  100. package/dist/runner/runtime/__tests__/integration.test.d.ts +2 -0
  101. package/dist/runner/runtime/__tests__/integration.test.d.ts.map +1 -0
  102. package/dist/runner/runtime/__tests__/integration.test.js +274 -0
  103. package/dist/runner/runtime/__tests__/integration.test.js.map +1 -0
  104. package/dist/runner/runtime/cli-runtime.d.ts +107 -0
  105. package/dist/runner/runtime/cli-runtime.d.ts.map +1 -0
  106. package/dist/runner/runtime/cli-runtime.js +341 -0
  107. package/dist/runner/runtime/cli-runtime.js.map +1 -0
  108. package/dist/runner/runtime/cli-session-path.d.ts +108 -0
  109. package/dist/runner/runtime/cli-session-path.d.ts.map +1 -0
  110. package/dist/runner/runtime/cli-session-path.js +173 -0
  111. package/dist/runner/runtime/cli-session-path.js.map +1 -0
  112. package/dist/runner/runtime/cli-session-watcher.d.ts +55 -0
  113. package/dist/runner/runtime/cli-session-watcher.d.ts.map +1 -0
  114. package/dist/runner/runtime/cli-session-watcher.js +187 -0
  115. package/dist/runner/runtime/cli-session-watcher.js.map +1 -0
  116. package/dist/runner/runtime/container-manager.d.ts +76 -0
  117. package/dist/runner/runtime/container-manager.d.ts.map +1 -0
  118. package/dist/runner/runtime/container-manager.js +229 -0
  119. package/dist/runner/runtime/container-manager.js.map +1 -0
  120. package/dist/runner/runtime/container-runner.d.ts +62 -0
  121. package/dist/runner/runtime/container-runner.d.ts.map +1 -0
  122. package/dist/runner/runtime/container-runner.js +235 -0
  123. package/dist/runner/runtime/container-runner.js.map +1 -0
  124. package/dist/runner/runtime/docker-config.d.ts +100 -0
  125. package/dist/runner/runtime/docker-config.d.ts.map +1 -0
  126. package/dist/runner/runtime/docker-config.js +98 -0
  127. package/dist/runner/runtime/docker-config.js.map +1 -0
  128. package/dist/runner/runtime/factory.d.ts +63 -0
  129. package/dist/runner/runtime/factory.d.ts.map +1 -0
  130. package/dist/runner/runtime/factory.js +68 -0
  131. package/dist/runner/runtime/factory.js.map +1 -0
  132. package/dist/runner/runtime/index.d.ts +20 -0
  133. package/dist/runner/runtime/index.d.ts.map +1 -0
  134. package/dist/runner/runtime/index.js +21 -0
  135. package/dist/runner/runtime/index.js.map +1 -0
  136. package/dist/runner/runtime/interface.d.ts +59 -0
  137. package/dist/runner/runtime/interface.d.ts.map +1 -0
  138. package/dist/runner/runtime/interface.js +12 -0
  139. package/dist/runner/runtime/interface.js.map +1 -0
  140. package/dist/runner/runtime/sdk-runtime.d.ts +46 -0
  141. package/dist/runner/runtime/sdk-runtime.d.ts.map +1 -0
  142. package/dist/runner/runtime/sdk-runtime.js +63 -0
  143. package/dist/runner/runtime/sdk-runtime.js.map +1 -0
  144. package/dist/runner/sdk-adapter.d.ts.map +1 -1
  145. package/dist/runner/sdk-adapter.js +28 -10
  146. package/dist/runner/sdk-adapter.js.map +1 -1
  147. package/dist/runner/types.d.ts +11 -1
  148. package/dist/runner/types.d.ts.map +1 -1
  149. package/dist/scheduler/__tests__/schedule-runner.test.js +61 -50
  150. package/dist/scheduler/__tests__/schedule-runner.test.js.map +1 -1
  151. package/dist/scheduler/schedule-runner.d.ts +1 -4
  152. package/dist/scheduler/schedule-runner.d.ts.map +1 -1
  153. package/dist/scheduler/schedule-runner.js +40 -8
  154. package/dist/scheduler/schedule-runner.js.map +1 -1
  155. package/dist/state/__tests__/session-schema.test.js +4 -0
  156. package/dist/state/__tests__/session-schema.test.js.map +1 -1
  157. package/dist/state/__tests__/session-validation.test.d.ts +2 -0
  158. package/dist/state/__tests__/session-validation.test.d.ts.map +1 -0
  159. package/dist/state/__tests__/session-validation.test.js +446 -0
  160. package/dist/state/__tests__/session-validation.test.js.map +1 -0
  161. package/dist/state/__tests__/session.test.js +68 -0
  162. package/dist/state/__tests__/session.test.js.map +1 -1
  163. package/dist/state/__tests__/working-directory-validation.test.d.ts +5 -0
  164. package/dist/state/__tests__/working-directory-validation.test.d.ts.map +1 -0
  165. package/dist/state/__tests__/working-directory-validation.test.js +101 -0
  166. package/dist/state/__tests__/working-directory-validation.test.js.map +1 -0
  167. package/dist/state/index.d.ts +2 -0
  168. package/dist/state/index.d.ts.map +1 -1
  169. package/dist/state/index.js +4 -0
  170. package/dist/state/index.js.map +1 -1
  171. package/dist/state/schemas/session-info.d.ts +32 -0
  172. package/dist/state/schemas/session-info.d.ts.map +1 -1
  173. package/dist/state/schemas/session-info.js +22 -0
  174. package/dist/state/schemas/session-info.js.map +1 -1
  175. package/dist/state/session-validation.d.ts +227 -0
  176. package/dist/state/session-validation.d.ts.map +1 -0
  177. package/dist/state/session-validation.js +448 -0
  178. package/dist/state/session-validation.js.map +1 -0
  179. package/dist/state/session.d.ts +23 -3
  180. package/dist/state/session.d.ts.map +1 -1
  181. package/dist/state/session.js +41 -6
  182. package/dist/state/session.js.map +1 -1
  183. package/dist/state/working-directory-validation.d.ts +52 -0
  184. package/dist/state/working-directory-validation.d.ts.map +1 -0
  185. package/dist/state/working-directory-validation.js +81 -0
  186. package/dist/state/working-directory-validation.js.map +1 -0
  187. package/package.json +7 -2
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Session validation utilities
3
+ *
4
+ * Provides timeout parsing and session expiration validation to prevent
5
+ * unexpected logouts when resuming expired sessions.
6
+ */
7
+ import type { SessionInfo } from "./schemas/session-info.js";
8
+ /**
9
+ * Result of session validation
10
+ */
11
+ export interface SessionValidationResult {
12
+ /** Whether the session is valid (not expired) */
13
+ valid: boolean;
14
+ /** If invalid, the reason why */
15
+ reason?: "expired" | "missing" | "invalid_timeout" | "file_not_found" | "runtime_mismatch";
16
+ /** Human-readable message */
17
+ message?: string;
18
+ /** Age of the session in milliseconds */
19
+ ageMs?: number;
20
+ /** Configured timeout in milliseconds */
21
+ timeoutMs?: number;
22
+ }
23
+ /**
24
+ * Options for session validation with file check
25
+ */
26
+ export interface SessionFileCheckOptions {
27
+ /**
28
+ * Path to the sessions directory (.herdctl/sessions)
29
+ * Required for Docker session file lookups
30
+ */
31
+ sessionsDir?: string;
32
+ }
33
+ /**
34
+ * Parse a timeout string into milliseconds
35
+ *
36
+ * Supports formats:
37
+ * - "30s" - 30 seconds
38
+ * - "5m" - 5 minutes
39
+ * - "1h" - 1 hour
40
+ * - "1d" - 1 day
41
+ * - "1w" - 1 week
42
+ *
43
+ * @param timeout - Timeout string (e.g., "30m", "1h")
44
+ * @returns Timeout in milliseconds, or null if invalid format
45
+ *
46
+ * @example
47
+ * ```typescript
48
+ * parseTimeout("30m"); // 1800000 (30 minutes in ms)
49
+ * parseTimeout("1h"); // 3600000 (1 hour in ms)
50
+ * parseTimeout("24h"); // 86400000 (24 hours in ms)
51
+ * parseTimeout("invalid"); // null
52
+ * ```
53
+ */
54
+ export declare function parseTimeout(timeout: string): number | null;
55
+ /**
56
+ * Default session timeout if not configured (24 hours)
57
+ * This is a reasonable default that prevents stale sessions from being resumed
58
+ * while still allowing long-running work sessions.
59
+ */
60
+ export declare const DEFAULT_SESSION_TIMEOUT_MS: number;
61
+ /**
62
+ * Check if a CLI session file exists on disk (native CLI, not Docker)
63
+ *
64
+ * @param workingDirectory - Working directory for the session
65
+ * @param sessionId - Session ID to check
66
+ * @returns true if the session file exists
67
+ */
68
+ export declare function cliSessionFileExists(workingDirectory: string, sessionId: string): Promise<boolean>;
69
+ /**
70
+ * Check if a Docker session file exists on disk
71
+ *
72
+ * Docker sessions are stored in .herdctl/docker-sessions/{session-id}.jsonl
73
+ * instead of the native ~/.claude/projects/ location.
74
+ *
75
+ * @param sessionsDir - Path to the sessions directory (.herdctl/sessions)
76
+ * @param sessionId - Session ID to check
77
+ * @returns true if the Docker session file exists
78
+ */
79
+ export declare function dockerSessionFileExists(sessionsDir: string, sessionId: string): Promise<boolean>;
80
+ /**
81
+ * Check if a session has expired based on its last_used_at timestamp
82
+ *
83
+ * @param session - The session info to validate
84
+ * @param timeout - Timeout string (e.g., "30m", "1h") or undefined for default
85
+ * @returns Validation result with expiration details
86
+ *
87
+ * @example
88
+ * ```typescript
89
+ * const session = await getSessionInfo(sessionsDir, agentName);
90
+ * if (session) {
91
+ * const validation = isSessionExpired(session, "1h");
92
+ * if (!validation.valid) {
93
+ * console.log(`Session expired: ${validation.message}`);
94
+ * // Clear the session and start fresh
95
+ * }
96
+ * }
97
+ * ```
98
+ */
99
+ export declare function validateSession(session: SessionInfo | null, timeout?: string): SessionValidationResult;
100
+ /**
101
+ * Validate a session including CLI session file existence check
102
+ *
103
+ * This async version of validateSession also checks if the CLI session file
104
+ * exists on disk (for CLI runtime sessions). Use this when validating sessions
105
+ * that will be resumed via CLI runtime.
106
+ *
107
+ * For Docker sessions (session.docker_enabled === true), checks the docker-sessions
108
+ * directory instead of the native ~/.claude/projects/ location.
109
+ *
110
+ * @param session - The session info to validate
111
+ * @param timeout - Timeout string (e.g., "30m", "1h") or undefined for default
112
+ * @param options - Optional configuration including sessionsDir for Docker lookups
113
+ * @returns Promise resolving to validation result
114
+ *
115
+ * @example
116
+ * ```typescript
117
+ * const session = await getSessionInfo(sessionsDir, agentName);
118
+ * if (session) {
119
+ * const validation = await validateSessionWithFileCheck(session, "1h", { sessionsDir });
120
+ * if (!validation.valid) {
121
+ * console.log(`Session invalid: ${validation.message}`);
122
+ * // Clear the session and start fresh
123
+ * }
124
+ * }
125
+ * ```
126
+ */
127
+ export declare function validateSessionWithFileCheck(session: SessionInfo | null, timeout?: string, options?: SessionFileCheckOptions): Promise<SessionValidationResult>;
128
+ /**
129
+ * Validate that a session's runtime context matches the current agent configuration
130
+ *
131
+ * Sessions are tied to a specific runtime configuration (SDK vs CLI, Docker vs native).
132
+ * If the runtime context changes, the session must be invalidated because:
133
+ * - CLI sessions use different session file locations than SDK sessions
134
+ * - Docker sessions are isolated from native sessions (different filesystems)
135
+ * - Session IDs are not portable across runtime contexts
136
+ *
137
+ * @param session - The session info to validate
138
+ * @param currentRuntimeType - Current runtime type from agent config ("sdk" or "cli")
139
+ * @param currentDockerEnabled - Current Docker enabled state from agent config
140
+ * @returns Validation result indicating if runtime context matches
141
+ *
142
+ * @example
143
+ * ```typescript
144
+ * const session = await getSessionInfo(sessionsDir, agentName);
145
+ * if (session) {
146
+ * const validation = validateRuntimeContext(
147
+ * session,
148
+ * agent.runtime ?? "sdk",
149
+ * agent.docker?.enabled ?? false
150
+ * );
151
+ * if (!validation.valid) {
152
+ * console.log(`Runtime context mismatch: ${validation.message}`);
153
+ * await clearSession(sessionsDir, agentName);
154
+ * }
155
+ * }
156
+ * ```
157
+ */
158
+ export declare function validateRuntimeContext(session: SessionInfo | null, currentRuntimeType: "sdk" | "cli", currentDockerEnabled: boolean): SessionValidationResult;
159
+ /**
160
+ * Format a duration in milliseconds to a human-readable string
161
+ *
162
+ * @param ms - Duration in milliseconds
163
+ * @returns Human-readable duration string
164
+ */
165
+ export declare function formatDuration(ms: number): string;
166
+ /**
167
+ * Check if an error from the SDK indicates an expired or invalid session
168
+ *
169
+ * The SDK may return errors when trying to resume an expired session.
170
+ * This function helps identify such errors for proper handling.
171
+ *
172
+ * Common error patterns from Claude CLI/SDK:
173
+ * - "session expired" / "session_expired" - explicit expiration
174
+ * - "session not found" / "invalid session" - session doesn't exist on server
175
+ * - "resume failed" - generic resume failure
176
+ * - "conversation not found" / "no conversation" - conversation ID invalid
177
+ * - "cannot resume" / "unable to resume" - resume operation failed
178
+ * - "stale session" - session is too old
179
+ *
180
+ * Also checks error codes for structured error responses.
181
+ *
182
+ * @param error - The error to check
183
+ * @returns true if this appears to be a session expiration error
184
+ */
185
+ export declare function isSessionExpiredError(error: Error): boolean;
186
+ /**
187
+ * Result of cleaning up expired sessions
188
+ */
189
+ export interface CleanupResult {
190
+ /** Number of sessions that were expired and removed */
191
+ removed: number;
192
+ /** Number of valid sessions that were kept */
193
+ kept: number;
194
+ /** Names of agents whose sessions were removed */
195
+ removedAgents: string[];
196
+ }
197
+ /**
198
+ * Clean up expired sessions from the sessions directory
199
+ *
200
+ * This function checks all sessions and removes those that have expired
201
+ * based on the provided timeout. Useful for periodic cleanup or startup.
202
+ *
203
+ * @param sessionsDir - Path to the sessions directory
204
+ * @param timeout - Timeout string (e.g., "24h") or undefined for default
205
+ * @param options - Additional options
206
+ * @returns Result containing counts and removed agent names
207
+ *
208
+ * @example
209
+ * ```typescript
210
+ * import { cleanupExpiredSessions } from "./state";
211
+ *
212
+ * // Clean up sessions older than 24 hours (default)
213
+ * const result = await cleanupExpiredSessions(sessionsDir);
214
+ * console.log(`Removed ${result.removed} expired sessions`);
215
+ *
216
+ * // Clean up sessions older than 1 hour
217
+ * const result = await cleanupExpiredSessions(sessionsDir, "1h");
218
+ * ```
219
+ */
220
+ export declare function cleanupExpiredSessions(sessionsDir: string, timeout?: string, options?: {
221
+ logger?: {
222
+ info?: (msg: string) => void;
223
+ warn: (msg: string) => void;
224
+ };
225
+ dryRun?: boolean;
226
+ }): Promise<CleanupResult>;
227
+ //# sourceMappingURL=session-validation.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session-validation.d.ts","sourceRoot":"","sources":["../../src/state/session-validation.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAKH,OAAO,KAAK,EAAE,WAAW,EAAE,MAAM,2BAA2B,CAAC;AAM7D;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC,iDAAiD;IACjD,KAAK,EAAE,OAAO,CAAC;IACf,iCAAiC;IACjC,MAAM,CAAC,EAAE,SAAS,GAAG,SAAS,GAAG,iBAAiB,GAAG,gBAAgB,GAAG,kBAAkB,CAAC;IAC3F,6BAA6B;IAC7B,OAAO,CAAC,EAAE,MAAM,CAAC;IACjB,yCAAyC;IACzC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,yCAAyC;IACzC,SAAS,CAAC,EAAE,MAAM,CAAC;CACpB;AAED;;GAEG;AACH,MAAM,WAAW,uBAAuB;IACtC;;;OAGG;IACH,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB;AAMD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,wBAAgB,YAAY,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAuB3D;AAED;;;;GAIG;AACH,eAAO,MAAM,0BAA0B,QAAsB,CAAC;AAM9D;;;;;;GAMG;AACH,wBAAsB,oBAAoB,CACxC,gBAAgB,EAAE,MAAM,EACxB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC,CAQlB;AAED;;;;;;;;;GASG;AACH,wBAAsB,uBAAuB,CAC3C,WAAW,EAAE,MAAM,EACnB,SAAS,EAAE,MAAM,GAChB,OAAO,CAAC,OAAO,CAAC,CAWlB;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,eAAe,CAC7B,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,OAAO,CAAC,EAAE,MAAM,GACf,uBAAuB,CAuEzB;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;GA0BG;AACH,wBAAsB,4BAA4B,CAChD,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,CAAC,EAAE,uBAAuB,GAChC,OAAO,CAAC,uBAAuB,CAAC,CAyClC;AAED;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA6BG;AACH,wBAAgB,sBAAsB,CACpC,OAAO,EAAE,WAAW,GAAG,IAAI,EAC3B,kBAAkB,EAAE,KAAK,GAAG,KAAK,EACjC,oBAAoB,EAAE,OAAO,GAC5B,uBAAuB,CAiCzB;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAAC,EAAE,EAAE,MAAM,GAAG,MAAM,CAgBjD;AAED;;;;;;;;;;;;;;;;;;GAkBG;AACH,wBAAgB,qBAAqB,CAAC,KAAK,EAAE,KAAK,GAAG,OAAO,CAsC3D;AAED;;GAEG;AACH,MAAM,WAAW,aAAa;IAC5B,uDAAuD;IACvD,OAAO,EAAE,MAAM,CAAC;IAChB,8CAA8C;IAC9C,IAAI,EAAE,MAAM,CAAC;IACb,kDAAkD;IAClD,aAAa,EAAE,MAAM,EAAE,CAAC;CACzB;AAED;;;;;;;;;;;;;;;;;;;;;;GAsBG;AACH,wBAAsB,sBAAsB,CAC1C,WAAW,EAAE,MAAM,EACnB,OAAO,CAAC,EAAE,MAAM,EAChB,OAAO,GAAE;IACP,MAAM,CAAC,EAAE;QAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;QAAC,IAAI,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAA;KAAE,CAAC;IACvE,MAAM,CAAC,EAAE,OAAO,CAAC;CACb,GACL,OAAO,CAAC,aAAa,CAAC,CA4CxB"}
@@ -0,0 +1,448 @@
1
+ /**
2
+ * Session validation utilities
3
+ *
4
+ * Provides timeout parsing and session expiration validation to prevent
5
+ * unexpected logouts when resuming expired sessions.
6
+ */
7
+ import { access } from "node:fs/promises";
8
+ import { join, dirname } from "node:path";
9
+ import { getCliSessionFile } from "../runner/runtime/cli-session-path.js";
10
+ // =============================================================================
11
+ // Timeout Parsing
12
+ // =============================================================================
13
+ /**
14
+ * Parse a timeout string into milliseconds
15
+ *
16
+ * Supports formats:
17
+ * - "30s" - 30 seconds
18
+ * - "5m" - 5 minutes
19
+ * - "1h" - 1 hour
20
+ * - "1d" - 1 day
21
+ * - "1w" - 1 week
22
+ *
23
+ * @param timeout - Timeout string (e.g., "30m", "1h")
24
+ * @returns Timeout in milliseconds, or null if invalid format
25
+ *
26
+ * @example
27
+ * ```typescript
28
+ * parseTimeout("30m"); // 1800000 (30 minutes in ms)
29
+ * parseTimeout("1h"); // 3600000 (1 hour in ms)
30
+ * parseTimeout("24h"); // 86400000 (24 hours in ms)
31
+ * parseTimeout("invalid"); // null
32
+ * ```
33
+ */
34
+ export function parseTimeout(timeout) {
35
+ const match = timeout.match(/^(\d+(?:\.\d+)?)(s|m|h|d|w)$/);
36
+ if (!match) {
37
+ return null;
38
+ }
39
+ const value = parseFloat(match[1]);
40
+ const unit = match[2];
41
+ switch (unit) {
42
+ case "s":
43
+ return value * 1000;
44
+ case "m":
45
+ return value * 60 * 1000;
46
+ case "h":
47
+ return value * 60 * 60 * 1000;
48
+ case "d":
49
+ return value * 24 * 60 * 60 * 1000;
50
+ case "w":
51
+ return value * 7 * 24 * 60 * 60 * 1000;
52
+ default:
53
+ return null;
54
+ }
55
+ }
56
+ /**
57
+ * Default session timeout if not configured (24 hours)
58
+ * This is a reasonable default that prevents stale sessions from being resumed
59
+ * while still allowing long-running work sessions.
60
+ */
61
+ export const DEFAULT_SESSION_TIMEOUT_MS = 24 * 60 * 60 * 1000; // 24 hours
62
+ // =============================================================================
63
+ // Session Validation
64
+ // =============================================================================
65
+ /**
66
+ * Check if a CLI session file exists on disk (native CLI, not Docker)
67
+ *
68
+ * @param workingDirectory - Working directory for the session
69
+ * @param sessionId - Session ID to check
70
+ * @returns true if the session file exists
71
+ */
72
+ export async function cliSessionFileExists(workingDirectory, sessionId) {
73
+ try {
74
+ const sessionFile = getCliSessionFile(workingDirectory, sessionId);
75
+ await access(sessionFile);
76
+ return true;
77
+ }
78
+ catch {
79
+ return false;
80
+ }
81
+ }
82
+ /**
83
+ * Check if a Docker session file exists on disk
84
+ *
85
+ * Docker sessions are stored in .herdctl/docker-sessions/{session-id}.jsonl
86
+ * instead of the native ~/.claude/projects/ location.
87
+ *
88
+ * @param sessionsDir - Path to the sessions directory (.herdctl/sessions)
89
+ * @param sessionId - Session ID to check
90
+ * @returns true if the Docker session file exists
91
+ */
92
+ export async function dockerSessionFileExists(sessionsDir, sessionId) {
93
+ try {
94
+ // Docker sessions are in .herdctl/docker-sessions/, sibling to .herdctl/sessions/
95
+ const stateDir = dirname(sessionsDir);
96
+ const dockerSessionsDir = join(stateDir, "docker-sessions");
97
+ const sessionFile = join(dockerSessionsDir, `${sessionId}.jsonl`);
98
+ await access(sessionFile);
99
+ return true;
100
+ }
101
+ catch {
102
+ return false;
103
+ }
104
+ }
105
+ /**
106
+ * Check if a session has expired based on its last_used_at timestamp
107
+ *
108
+ * @param session - The session info to validate
109
+ * @param timeout - Timeout string (e.g., "30m", "1h") or undefined for default
110
+ * @returns Validation result with expiration details
111
+ *
112
+ * @example
113
+ * ```typescript
114
+ * const session = await getSessionInfo(sessionsDir, agentName);
115
+ * if (session) {
116
+ * const validation = isSessionExpired(session, "1h");
117
+ * if (!validation.valid) {
118
+ * console.log(`Session expired: ${validation.message}`);
119
+ * // Clear the session and start fresh
120
+ * }
121
+ * }
122
+ * ```
123
+ */
124
+ export function validateSession(session, timeout) {
125
+ // Handle missing session
126
+ if (!session) {
127
+ return {
128
+ valid: false,
129
+ reason: "missing",
130
+ message: "No session found",
131
+ };
132
+ }
133
+ // Parse timeout or use default
134
+ let timeoutMs;
135
+ if (timeout) {
136
+ const parsed = parseTimeout(timeout);
137
+ if (parsed === null) {
138
+ return {
139
+ valid: false,
140
+ reason: "invalid_timeout",
141
+ message: `Invalid timeout format: "${timeout}". Expected format like "30m", "1h", "24h"`,
142
+ };
143
+ }
144
+ timeoutMs = parsed;
145
+ }
146
+ else {
147
+ timeoutMs = DEFAULT_SESSION_TIMEOUT_MS;
148
+ }
149
+ // Calculate session age from last_used_at
150
+ const lastUsedAt = new Date(session.last_used_at).getTime();
151
+ const now = Date.now();
152
+ const ageMs = now - lastUsedAt;
153
+ // Handle invalid date parsing (NaN) - treat as expired to force a fresh session
154
+ // This prevents unexpected behavior from corrupted session files
155
+ if (Number.isNaN(ageMs)) {
156
+ return {
157
+ valid: false,
158
+ reason: "expired",
159
+ message: `Session has invalid last_used_at timestamp: "${session.last_used_at}"`,
160
+ timeoutMs,
161
+ };
162
+ }
163
+ // Handle future timestamps (negative age) - could indicate clock skew or timezone issues
164
+ // Treat as valid but log the unusual state; the session will be refreshed on next use
165
+ if (ageMs < 0) {
166
+ return {
167
+ valid: true,
168
+ ageMs: 0, // Report as just used
169
+ timeoutMs,
170
+ };
171
+ }
172
+ // Check if expired
173
+ if (ageMs > timeoutMs) {
174
+ const ageMinutes = Math.round(ageMs / (60 * 1000));
175
+ const timeoutMinutes = Math.round(timeoutMs / (60 * 1000));
176
+ return {
177
+ valid: false,
178
+ reason: "expired",
179
+ message: `Session expired: last used ${formatDuration(ageMs)} ago, timeout is ${formatDuration(timeoutMs)}`,
180
+ ageMs,
181
+ timeoutMs,
182
+ };
183
+ }
184
+ return {
185
+ valid: true,
186
+ ageMs,
187
+ timeoutMs,
188
+ };
189
+ }
190
+ /**
191
+ * Validate a session including CLI session file existence check
192
+ *
193
+ * This async version of validateSession also checks if the CLI session file
194
+ * exists on disk (for CLI runtime sessions). Use this when validating sessions
195
+ * that will be resumed via CLI runtime.
196
+ *
197
+ * For Docker sessions (session.docker_enabled === true), checks the docker-sessions
198
+ * directory instead of the native ~/.claude/projects/ location.
199
+ *
200
+ * @param session - The session info to validate
201
+ * @param timeout - Timeout string (e.g., "30m", "1h") or undefined for default
202
+ * @param options - Optional configuration including sessionsDir for Docker lookups
203
+ * @returns Promise resolving to validation result
204
+ *
205
+ * @example
206
+ * ```typescript
207
+ * const session = await getSessionInfo(sessionsDir, agentName);
208
+ * if (session) {
209
+ * const validation = await validateSessionWithFileCheck(session, "1h", { sessionsDir });
210
+ * if (!validation.valid) {
211
+ * console.log(`Session invalid: ${validation.message}`);
212
+ * // Clear the session and start fresh
213
+ * }
214
+ * }
215
+ * ```
216
+ */
217
+ export async function validateSessionWithFileCheck(session, timeout, options) {
218
+ // First do basic validation (expiration, etc.)
219
+ const basicValidation = validateSession(session, timeout);
220
+ if (!basicValidation.valid) {
221
+ return basicValidation;
222
+ }
223
+ // If session exists and isn't expired, check if session file exists
224
+ if (session) {
225
+ let fileExists = false;
226
+ if (session.docker_enabled && options?.sessionsDir) {
227
+ // Docker sessions are stored in .herdctl/docker-sessions/
228
+ fileExists = await dockerSessionFileExists(options.sessionsDir, session.session_id);
229
+ }
230
+ else if (session.working_directory) {
231
+ // Native CLI sessions are stored in ~/.claude/projects/
232
+ fileExists = await cliSessionFileExists(session.working_directory, session.session_id);
233
+ }
234
+ else {
235
+ // No working directory and not Docker - skip file check
236
+ return basicValidation;
237
+ }
238
+ if (!fileExists) {
239
+ const location = session.docker_enabled ? "Docker" : "CLI";
240
+ return {
241
+ valid: false,
242
+ reason: "file_not_found",
243
+ message: `${location} session file not found for session ${session.session_id}`,
244
+ ageMs: basicValidation.ageMs,
245
+ timeoutMs: basicValidation.timeoutMs,
246
+ };
247
+ }
248
+ }
249
+ return basicValidation;
250
+ }
251
+ /**
252
+ * Validate that a session's runtime context matches the current agent configuration
253
+ *
254
+ * Sessions are tied to a specific runtime configuration (SDK vs CLI, Docker vs native).
255
+ * If the runtime context changes, the session must be invalidated because:
256
+ * - CLI sessions use different session file locations than SDK sessions
257
+ * - Docker sessions are isolated from native sessions (different filesystems)
258
+ * - Session IDs are not portable across runtime contexts
259
+ *
260
+ * @param session - The session info to validate
261
+ * @param currentRuntimeType - Current runtime type from agent config ("sdk" or "cli")
262
+ * @param currentDockerEnabled - Current Docker enabled state from agent config
263
+ * @returns Validation result indicating if runtime context matches
264
+ *
265
+ * @example
266
+ * ```typescript
267
+ * const session = await getSessionInfo(sessionsDir, agentName);
268
+ * if (session) {
269
+ * const validation = validateRuntimeContext(
270
+ * session,
271
+ * agent.runtime ?? "sdk",
272
+ * agent.docker?.enabled ?? false
273
+ * );
274
+ * if (!validation.valid) {
275
+ * console.log(`Runtime context mismatch: ${validation.message}`);
276
+ * await clearSession(sessionsDir, agentName);
277
+ * }
278
+ * }
279
+ * ```
280
+ */
281
+ export function validateRuntimeContext(session, currentRuntimeType, currentDockerEnabled) {
282
+ // Handle missing session
283
+ if (!session) {
284
+ return {
285
+ valid: false,
286
+ reason: "missing",
287
+ message: "No session found",
288
+ };
289
+ }
290
+ // Check runtime type mismatch
291
+ if (session.runtime_type !== currentRuntimeType) {
292
+ return {
293
+ valid: false,
294
+ reason: "runtime_mismatch",
295
+ message: `Runtime type changed from "${session.runtime_type}" to "${currentRuntimeType}". Session must be recreated for the new runtime.`,
296
+ };
297
+ }
298
+ // Check Docker enabled mismatch
299
+ if (session.docker_enabled !== currentDockerEnabled) {
300
+ const oldContext = session.docker_enabled ? "Docker" : "native";
301
+ const newContext = currentDockerEnabled ? "Docker" : "native";
302
+ return {
303
+ valid: false,
304
+ reason: "runtime_mismatch",
305
+ message: `Docker context changed from ${oldContext} to ${newContext}. Session must be recreated for the new context.`,
306
+ };
307
+ }
308
+ return {
309
+ valid: true,
310
+ };
311
+ }
312
+ /**
313
+ * Format a duration in milliseconds to a human-readable string
314
+ *
315
+ * @param ms - Duration in milliseconds
316
+ * @returns Human-readable duration string
317
+ */
318
+ export function formatDuration(ms) {
319
+ const seconds = Math.floor(ms / 1000);
320
+ const minutes = Math.floor(seconds / 60);
321
+ const hours = Math.floor(minutes / 60);
322
+ const days = Math.floor(hours / 24);
323
+ if (days > 0) {
324
+ return `${days}d ${hours % 24}h`;
325
+ }
326
+ if (hours > 0) {
327
+ return `${hours}h ${minutes % 60}m`;
328
+ }
329
+ if (minutes > 0) {
330
+ return `${minutes}m`;
331
+ }
332
+ return `${seconds}s`;
333
+ }
334
+ /**
335
+ * Check if an error from the SDK indicates an expired or invalid session
336
+ *
337
+ * The SDK may return errors when trying to resume an expired session.
338
+ * This function helps identify such errors for proper handling.
339
+ *
340
+ * Common error patterns from Claude CLI/SDK:
341
+ * - "session expired" / "session_expired" - explicit expiration
342
+ * - "session not found" / "invalid session" - session doesn't exist on server
343
+ * - "resume failed" - generic resume failure
344
+ * - "conversation not found" / "no conversation" - conversation ID invalid
345
+ * - "cannot resume" / "unable to resume" - resume operation failed
346
+ * - "stale session" - session is too old
347
+ *
348
+ * Also checks error codes for structured error responses.
349
+ *
350
+ * @param error - The error to check
351
+ * @returns true if this appears to be a session expiration error
352
+ */
353
+ export function isSessionExpiredError(error) {
354
+ const message = error.message.toLowerCase();
355
+ // Check error codes for structured errors (e.g., from SDK responses)
356
+ const errorCode = error.code?.toLowerCase() ?? "";
357
+ if (errorCode === "session_expired" ||
358
+ errorCode === "invalid_session" ||
359
+ errorCode === "session_not_found" ||
360
+ errorCode === "conversation_expired" ||
361
+ errorCode === "conversation_not_found") {
362
+ return true;
363
+ }
364
+ return (
365
+ // Explicit session expiration
366
+ message.includes("session expired") ||
367
+ message.includes("session_expired") ||
368
+ message.includes("has expired") ||
369
+ message.includes("stale session") ||
370
+ // Session not found on server
371
+ message.includes("session not found") ||
372
+ message.includes("invalid session") ||
373
+ message.includes("session does not exist") ||
374
+ message.includes("session id") && message.includes("not found") ||
375
+ // Conversation/context issues (Claude CLI uses these)
376
+ message.includes("conversation not found") ||
377
+ message.includes("no conversation") ||
378
+ message.includes("conversation does not exist") ||
379
+ message.includes("invalid conversation") ||
380
+ // Resume operation failures
381
+ message.includes("resume failed") ||
382
+ message.includes("cannot resume") ||
383
+ message.includes("unable to resume") ||
384
+ message.includes("failed to resume") ||
385
+ message.includes("could not resume"));
386
+ }
387
+ /**
388
+ * Clean up expired sessions from the sessions directory
389
+ *
390
+ * This function checks all sessions and removes those that have expired
391
+ * based on the provided timeout. Useful for periodic cleanup or startup.
392
+ *
393
+ * @param sessionsDir - Path to the sessions directory
394
+ * @param timeout - Timeout string (e.g., "24h") or undefined for default
395
+ * @param options - Additional options
396
+ * @returns Result containing counts and removed agent names
397
+ *
398
+ * @example
399
+ * ```typescript
400
+ * import { cleanupExpiredSessions } from "./state";
401
+ *
402
+ * // Clean up sessions older than 24 hours (default)
403
+ * const result = await cleanupExpiredSessions(sessionsDir);
404
+ * console.log(`Removed ${result.removed} expired sessions`);
405
+ *
406
+ * // Clean up sessions older than 1 hour
407
+ * const result = await cleanupExpiredSessions(sessionsDir, "1h");
408
+ * ```
409
+ */
410
+ export async function cleanupExpiredSessions(sessionsDir, timeout, options = {}) {
411
+ const { logger = console, dryRun = false } = options;
412
+ // Import dynamically to avoid circular dependencies
413
+ const { listSessions, clearSession } = await import("./session.js");
414
+ const result = {
415
+ removed: 0,
416
+ kept: 0,
417
+ removedAgents: [],
418
+ };
419
+ // List all sessions
420
+ const sessions = await listSessions(sessionsDir, { logger });
421
+ for (const session of sessions) {
422
+ const validation = validateSession(session, timeout);
423
+ if (!validation.valid && validation.reason === "expired") {
424
+ if (!dryRun) {
425
+ try {
426
+ await clearSession(sessionsDir, session.agent_name);
427
+ result.removed++;
428
+ result.removedAgents.push(session.agent_name);
429
+ logger.info?.(`Cleaned up expired session for ${session.agent_name}`);
430
+ }
431
+ catch (error) {
432
+ logger.warn(`Failed to clean up session for ${session.agent_name}: ${error.message}`);
433
+ }
434
+ }
435
+ else {
436
+ // Dry run - just count
437
+ result.removed++;
438
+ result.removedAgents.push(session.agent_name);
439
+ logger.info?.(`[DRY RUN] Would clean up expired session for ${session.agent_name}`);
440
+ }
441
+ }
442
+ else {
443
+ result.kept++;
444
+ }
445
+ }
446
+ return result;
447
+ }
448
+ //# sourceMappingURL=session-validation.js.map