@ekkos/cli 0.2.6 → 0.2.8

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.
@@ -0,0 +1,227 @@
1
+ /**
2
+ * Stream capture types for mid-turn context preservation
3
+ */
4
+ export type TurnStatus = 'in_progress' | 'complete';
5
+ /**
6
+ * How a turn was sealed (closed)
7
+ */
8
+ export type SealReason = 'user_boundary' | 'explicit' | 'process_exit' | 'context_wall';
9
+ export interface ToolEvent {
10
+ kind: 'tool_use' | 'tool_result';
11
+ id: string;
12
+ name?: string;
13
+ input?: unknown;
14
+ output?: unknown;
15
+ is_error?: boolean;
16
+ ts: string;
17
+ transcript_line_uuid?: string;
18
+ }
19
+ export interface StreamTurn {
20
+ session_id: string;
21
+ session_name?: string;
22
+ turn_id: number;
23
+ started_at: string;
24
+ updated_at: string;
25
+ user_query?: string;
26
+ assistant_stream: string;
27
+ assistant_response?: string;
28
+ status: TurnStatus;
29
+ stream_offset: number;
30
+ tools: ToolEvent[];
31
+ files_referenced?: string[];
32
+ files_modified?: string[];
33
+ last_text_delta_ts?: string;
34
+ last_tool_event_ts?: string;
35
+ sealed_at?: string;
36
+ sealed_by?: SealReason;
37
+ }
38
+ export type StreamEvent = {
39
+ kind: 'session_start';
40
+ event_id: string;
41
+ session_id: string;
42
+ session_name?: string;
43
+ ts: string;
44
+ } | {
45
+ kind: 'user_query';
46
+ event_id: string;
47
+ turn_id: number;
48
+ query: string;
49
+ ts: string;
50
+ transcript_line_uuid?: string;
51
+ } | {
52
+ kind: 'assistant_text_delta';
53
+ event_id: string;
54
+ turn_id: number;
55
+ delta: string;
56
+ offset: number;
57
+ ts: string;
58
+ transcript_line_uuid?: string;
59
+ } | {
60
+ kind: 'tool_use';
61
+ event_id: string;
62
+ turn_id: number;
63
+ id: string;
64
+ name: string;
65
+ input?: unknown;
66
+ ts: string;
67
+ transcript_line_uuid?: string;
68
+ } | {
69
+ kind: 'tool_result';
70
+ event_id: string;
71
+ turn_id: number;
72
+ id: string;
73
+ output?: unknown;
74
+ is_error?: boolean;
75
+ ts: string;
76
+ transcript_line_uuid?: string;
77
+ } | {
78
+ kind: 'seal_turn';
79
+ event_id: string;
80
+ turn_id: number;
81
+ reason: SealReason;
82
+ ts: string;
83
+ };
84
+ export interface TranscriptLine {
85
+ type: 'user' | 'assistant' | string;
86
+ uuid: string;
87
+ parentUuid?: string | null;
88
+ timestamp: string;
89
+ sessionId: string;
90
+ cwd?: string;
91
+ gitBranch?: string;
92
+ version?: string;
93
+ isMeta?: boolean;
94
+ message: {
95
+ role: 'user' | 'assistant';
96
+ model?: string;
97
+ content: string | ContentBlock[];
98
+ };
99
+ }
100
+ export interface ContentBlock {
101
+ type: 'text' | 'thinking' | 'tool_use' | 'tool_result';
102
+ text?: string;
103
+ thinking?: string;
104
+ signature?: string;
105
+ id?: string;
106
+ name?: string;
107
+ input?: unknown;
108
+ tool_use_id?: string;
109
+ content?: unknown;
110
+ is_error?: boolean;
111
+ }
112
+ export interface TailerState {
113
+ transcriptPath: string;
114
+ sessionId: string;
115
+ sessionName?: string;
116
+ readOffset: number;
117
+ pendingBuffer: string;
118
+ currentTurnId: number;
119
+ currentTurnStatus: TurnStatus;
120
+ lastActivityTs: number;
121
+ }
122
+ /**
123
+ * Stream state - where are we in the conversation flow?
124
+ */
125
+ export type StreamState = 'idle' | 'assistant_streaming' | 'tool_running' | 'interrupted' | 'wall_hit';
126
+ /**
127
+ * Stream state machine - deterministic resume anchor
128
+ */
129
+ export interface StreamStateMachine {
130
+ session_id: string;
131
+ session_name?: string;
132
+ state: StreamState;
133
+ state_entered_at: string;
134
+ last_complete_turn_id: number;
135
+ current_turn_id: number;
136
+ in_progress_text_head: string;
137
+ in_progress_text_tail: string;
138
+ in_progress_total_chars: number;
139
+ open_loops: Array<{
140
+ type: 'tool' | 'plan_step';
141
+ id: string;
142
+ name: string;
143
+ started_at: string;
144
+ }>;
145
+ last_event_ts: string;
146
+ last_checkpoint_ts: string;
147
+ stream_bytes_captured: number;
148
+ events_captured: number;
149
+ }
150
+ /**
151
+ * In-progress checkpoint - saved every N seconds for crash recovery
152
+ */
153
+ export interface StreamCheckpoint {
154
+ session_id: string;
155
+ turn_id: number;
156
+ checkpoint_ts: string;
157
+ text_head: string;
158
+ text_tail: string;
159
+ state: StreamState;
160
+ open_loops: Array<{
161
+ type: string;
162
+ id: string;
163
+ name: string;
164
+ started_at: string;
165
+ }>;
166
+ total_chars: number;
167
+ events_since_last_checkpoint: number;
168
+ }
169
+ /**
170
+ * Open loop - work started but not completed
171
+ */
172
+ export interface OpenLoop {
173
+ type: 'tool' | 'plan_step' | 'user_request';
174
+ name: string;
175
+ status: 'in_progress' | 'blocked' | 'waiting';
176
+ started_at: string;
177
+ context?: string;
178
+ }
179
+ /**
180
+ * Work phase derived from activity patterns
181
+ */
182
+ export type WorkPhase = 'Plan' | 'Implement' | 'Verify' | 'Fix' | 'Deploy';
183
+ /**
184
+ * WorkState Capsule - deterministic resume anchor for Time Machine
185
+ *
186
+ * This is the machine-derived state that guarantees perfect continuation.
187
+ * It's built from streaming capture data and injected by /continue.
188
+ */
189
+ export interface WorkStateCapsule {
190
+ goal: string;
191
+ phase: WorkPhase;
192
+ next_step: string;
193
+ open_loops: OpenLoop[];
194
+ files_in_play: string[];
195
+ decisions_made: string[];
196
+ blockers?: string[];
197
+ last_good_output_tail: string;
198
+ continuation_instruction: string;
199
+ generated_at: string;
200
+ turn_id: number;
201
+ session_id: string;
202
+ }
203
+ /**
204
+ * Timeline event for Time Machine display
205
+ */
206
+ export interface TimelineEvent {
207
+ turn_id: number;
208
+ timestamp: string;
209
+ phase: WorkPhase;
210
+ summary: string;
211
+ impact?: string;
212
+ tools_used: string[];
213
+ files_touched: string[];
214
+ }
215
+ /**
216
+ * Evidence entry for audit trail
217
+ */
218
+ export interface EvidenceEntry {
219
+ turn_id: number;
220
+ timestamp: string;
221
+ tool_events: ToolEvent[];
222
+ files_touched: string[];
223
+ diff_summary?: string;
224
+ transcript_line_uuid: string;
225
+ transcript_path: string;
226
+ raw_transcript_offset?: number;
227
+ }
@@ -0,0 +1,5 @@
1
+ "use strict";
2
+ /**
3
+ * Stream capture types for mid-turn context preservation
4
+ */
5
+ Object.defineProperty(exports, "__esModule", { value: true });
@@ -44,6 +44,7 @@ const os = __importStar(require("os"));
44
44
  const child_process_1 = require("child_process");
45
45
  const state_1 = require("../utils/state");
46
46
  const doctor_1 = require("./doctor");
47
+ const stream_tailer_1 = require("../capture/stream-tailer");
47
48
  // Try to load node-pty (may fail on Node 24+)
48
49
  let pty = null;
49
50
  try {
@@ -107,6 +108,25 @@ const SESSION_NAME_IN_STATUS_REGEX = /·\s*([a-z]+-[a-z]+-[a-z]+)\s*·/i;
107
108
  // Weaker signal: any 3-word slug (word-word-word pattern)
108
109
  const SESSION_NAME_REGEX = /\b([a-z]+-[a-z]+-[a-z]+)\b/i;
109
110
  // ═══════════════════════════════════════════════════════════════════════════
111
+ // SESSION NAME VALIDATION (MUST use words from session-words.json)
112
+ // This is the SOURCE OF TRUTH for valid session names
113
+ // ═══════════════════════════════════════════════════════════════════════════
114
+ const session_words_json_1 = __importDefault(require("../utils/session-words.json"));
115
+ const VALID_ADJECTIVES = new Set(session_words_json_1.default.adjectives.map((w) => w.toLowerCase()));
116
+ const VALID_NOUNS = new Set(session_words_json_1.default.nouns.map((w) => w.toLowerCase()));
117
+ const VALID_VERBS = new Set(session_words_json_1.default.verbs.map((w) => w.toLowerCase()));
118
+ /**
119
+ * Validate that a detected session name actually uses words from our word lists.
120
+ * This prevents false positives like "x-github-event" from being detected.
121
+ */
122
+ function isValidSessionName(name) {
123
+ const parts = name.toLowerCase().split('-');
124
+ if (parts.length !== 3)
125
+ return false;
126
+ const [adj, noun, verb] = parts;
127
+ return VALID_ADJECTIVES.has(adj) && VALID_NOUNS.has(noun) && VALID_VERBS.has(verb);
128
+ }
129
+ // ═══════════════════════════════════════════════════════════════════════════
110
130
  // LOGGING (FILE ONLY DURING TUI - NO TERMINAL CORRUPTION)
111
131
  // ═══════════════════════════════════════════════════════════════════════════
112
132
  let _debugLogPath = path.join(os.homedir(), '.ekkos', 'auto-continue.debug.log');
@@ -491,9 +511,55 @@ async function run(options) {
491
511
  (0, state_1.clearAutoClearFlag)();
492
512
  // Track state
493
513
  let currentSession = options.session || (0, state_1.getCurrentSessionName)();
514
+ // ════════════════════════════════════════════════════════════════════════════
515
+ // MULTI-SESSION SUPPORT: Register this process as an active session
516
+ // This prevents state collision when multiple Claude Code instances run
517
+ // ════════════════════════════════════════════════════════════════════════════
518
+ (0, state_1.registerActiveSession)('pending', // Session ID not yet known
519
+ currentSession || 'initializing', process.cwd());
520
+ dlog(`Registered active session (PID ${process.pid})`);
521
+ // Show active sessions count if verbose
522
+ if (verbose) {
523
+ const activeSessions = (0, state_1.getActiveSessions)();
524
+ if (activeSessions.length > 1) {
525
+ console.log(chalk_1.default.cyan(` 📊 Active ekkOS sessions: ${activeSessions.length}`));
526
+ }
527
+ }
494
528
  let isAutoClearInProgress = false;
495
529
  let transcriptPath = null;
496
530
  let currentSessionId = null;
531
+ // Stream tailer for mid-turn context capture
532
+ let streamTailer = null;
533
+ const streamCacheDir = path.join(os.homedir(), '.ekkos', 'cache', 'sessions');
534
+ // Helper to start stream tailer when we have transcript path
535
+ function startStreamTailer(tPath, sId, sName) {
536
+ if (streamTailer)
537
+ return; // Already running
538
+ try {
539
+ streamTailer = new stream_tailer_1.StreamTailer({
540
+ transcriptPath: tPath,
541
+ sessionId: sId,
542
+ sessionName: sName,
543
+ cacheDir: streamCacheDir,
544
+ onEvent: (event) => {
545
+ dlog(`Stream event: ${event.kind} (turn ${event.turn_id || 'n/a'})`);
546
+ }
547
+ });
548
+ streamTailer.start();
549
+ dlog(`Stream tailer started for ${sName || sId}`);
550
+ }
551
+ catch (err) {
552
+ dlog(`Failed to start stream tailer: ${err.message}`);
553
+ }
554
+ }
555
+ // Helper to stop stream tailer
556
+ function stopStreamTailer() {
557
+ if (streamTailer) {
558
+ streamTailer.stop();
559
+ streamTailer = null;
560
+ dlog('Stream tailer stopped');
561
+ }
562
+ }
497
563
  // ══════════════════════════════════════════════════════════════════════════
498
564
  // SESSION NAME TRACKING (from live TUI output)
499
565
  // Claude prints: "· Turn N · groovy-koala-saves · 📅"
@@ -682,16 +748,26 @@ async function run(options) {
682
748
  }
683
749
  }
684
750
  // PRIORITY 4: Persisted state (last resort)
751
+ // CRITICAL: Always validate sessionName - persisted state may have invalid names
752
+ // like "claude-plugins-official" from directory names or false positives
685
753
  if (!sessionToRestore) {
686
754
  const state = (0, state_1.getState)();
687
- sessionToRestore = state?.sessionName || null;
755
+ const persistedName = state?.sessionName || null;
688
756
  const sessionId = state?.sessionId || null;
689
- // If still no session name but we have an ID, generate it
690
- if (!sessionToRestore && sessionId) {
691
- sessionToRestore = (0, state_1.uuidToWords)(sessionId);
757
+ // Validate persisted name - only use if it passes word list validation
758
+ if (persistedName && isValidSessionName(persistedName)) {
759
+ sessionToRestore = persistedName;
760
+ dlog(`Using validated persisted state: ${sessionToRestore}`);
692
761
  }
693
- if (sessionToRestore) {
694
- dlog(`Using persisted state (last resort): ${sessionToRestore}`);
762
+ else if (sessionId) {
763
+ // Derive from UUID if persisted name is invalid or missing
764
+ sessionToRestore = (0, state_1.uuidToWords)(sessionId);
765
+ if (persistedName) {
766
+ dlog(`Persisted name "${persistedName}" invalid, derived from UUID: ${sessionToRestore}`);
767
+ }
768
+ else {
769
+ dlog(`No persisted name, derived from UUID: ${sessionToRestore}`);
770
+ }
695
771
  }
696
772
  }
697
773
  const sessionDisplay = sessionToRestore || 'unknown-session';
@@ -777,18 +853,35 @@ async function run(options) {
777
853
  outputBuffer = outputBuffer.slice(-2000);
778
854
  }
779
855
  // Try to extract transcript path from output (Claude shows it on startup)
780
- const transcriptMatch = data.match(/transcript[_\s]?(?:path)?[:\s]+([^\s\n]+\.json)/i);
856
+ const transcriptMatch = data.match(/transcript[_\s]?(?:path)?[:\s]+([^\s\n]+\.jsonl?)/i);
781
857
  if (transcriptMatch) {
782
858
  transcriptPath = transcriptMatch[1];
783
859
  dlog(`Detected transcript: ${transcriptPath}`);
860
+ // Start tailer if we have session ID
861
+ if (currentSessionId && transcriptPath) {
862
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
863
+ }
784
864
  }
785
865
  // Try to extract session ID from output (fallback - Claude rarely prints this)
786
866
  const sessionMatch = data.match(/session[_\s]?(?:id)?[:\s]+([a-f0-9-]{36})/i);
787
867
  if (sessionMatch) {
788
868
  currentSessionId = sessionMatch[1];
789
869
  currentSession = (0, state_1.uuidToWords)(currentSessionId);
870
+ // Update THIS process's session entry (not global state.json)
871
+ (0, state_1.updateCurrentProcessSession)(currentSessionId, currentSession);
872
+ // Also update global state for backwards compatibility
790
873
  (0, state_1.updateState)({ sessionId: currentSessionId, sessionName: currentSession });
791
874
  dlog(`Session detected from UUID: ${currentSession}`);
875
+ // Try to find/construct transcript path from session ID
876
+ if (!transcriptPath) {
877
+ const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
878
+ const possibleTranscript = path.join(os.homedir(), '.claude', 'projects', encodedCwd, `${currentSessionId}.jsonl`);
879
+ if (fs.existsSync(possibleTranscript)) {
880
+ transcriptPath = possibleTranscript;
881
+ dlog(`Found transcript from session ID: ${transcriptPath}`);
882
+ startStreamTailer(transcriptPath, currentSessionId, currentSession || undefined);
883
+ }
884
+ }
792
885
  }
793
886
  // ════════════════════════════════════════════════════════════════════════
794
887
  // SESSION NAME DETECTION (PRIMARY METHOD)
@@ -800,14 +893,47 @@ async function run(options) {
800
893
  const statusMatch = plain.match(SESSION_NAME_IN_STATUS_REGEX);
801
894
  if (statusMatch) {
802
895
  const detectedSession = statusMatch[1].toLowerCase();
803
- // Only update if different (avoid log spam)
804
- if (detectedSession !== lastSeenSessionName) {
896
+ // Validate against word lists (SOURCE OF TRUTH)
897
+ if (!isValidSessionName(detectedSession)) {
898
+ dlog(`Session rejected (invalid words): ${detectedSession}`);
899
+ }
900
+ else if (detectedSession !== lastSeenSessionName) {
901
+ // Only update if different (avoid log spam)
805
902
  lastSeenSessionName = detectedSession;
806
903
  lastSeenSessionAt = Date.now();
807
904
  currentSession = lastSeenSessionName;
808
905
  observedSessionThisRun = true; // Mark that we've seen a session in THIS process
906
+ // Update THIS process's session entry (not global state.json)
907
+ (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
908
+ // Also update global state for backwards compatibility
809
909
  (0, state_1.updateState)({ sessionName: currentSession });
810
910
  dlog(`Session detected from status line: ${currentSession} (observedSessionThisRun=true)`);
911
+ // Try to start stream tailer - scan for matching transcript file
912
+ if (!streamTailer) {
913
+ const encodedCwd = process.cwd().replace(/\//g, '-').replace(/^-/, '');
914
+ const projectDir = path.join(os.homedir(), '.claude', 'projects', encodedCwd);
915
+ try {
916
+ const files = fs.readdirSync(projectDir);
917
+ // Find most recent .jsonl file (likely current session)
918
+ const jsonlFiles = files
919
+ .filter(f => f.endsWith('.jsonl'))
920
+ .map(f => ({
921
+ name: f,
922
+ path: path.join(projectDir, f),
923
+ mtime: fs.statSync(path.join(projectDir, f)).mtimeMs
924
+ }))
925
+ .sort((a, b) => b.mtime - a.mtime);
926
+ if (jsonlFiles.length > 0) {
927
+ transcriptPath = jsonlFiles[0].path;
928
+ currentSessionId = jsonlFiles[0].name.replace('.jsonl', '');
929
+ dlog(`Found transcript from project scan: ${transcriptPath}`);
930
+ startStreamTailer(transcriptPath, currentSessionId, currentSession);
931
+ }
932
+ }
933
+ catch {
934
+ // Project dir might not exist yet
935
+ }
936
+ }
811
937
  }
812
938
  else {
813
939
  // Same session, just update timestamp
@@ -819,11 +945,18 @@ async function run(options) {
819
945
  const anyMatch = plain.match(SESSION_NAME_REGEX);
820
946
  if (anyMatch) {
821
947
  const detectedSession = anyMatch[1].toLowerCase();
822
- if (detectedSession !== lastSeenSessionName) {
948
+ // Validate against word lists (SOURCE OF TRUTH)
949
+ if (!isValidSessionName(detectedSession)) {
950
+ dlog(`Session rejected (invalid words): ${detectedSession}`);
951
+ }
952
+ else if (detectedSession !== lastSeenSessionName) {
823
953
  lastSeenSessionName = detectedSession;
824
954
  lastSeenSessionAt = Date.now();
825
955
  currentSession = lastSeenSessionName;
826
956
  observedSessionThisRun = true; // Mark that we've seen a session in THIS process
957
+ // Update THIS process's session entry (not global state.json)
958
+ (0, state_1.updateCurrentProcessSession)(currentSessionId || 'unknown', currentSession);
959
+ // Also update global state for backwards compatibility
827
960
  (0, state_1.updateState)({ sessionName: currentSession });
828
961
  dlog(`Session detected from generic match: ${currentSession} (observedSessionThisRun=true)`);
829
962
  }
@@ -847,6 +980,8 @@ async function run(options) {
847
980
  // Handle PTY exit
848
981
  shell.onExit(({ exitCode }) => {
849
982
  (0, state_1.clearAutoClearFlag)();
983
+ stopStreamTailer(); // Stop stream capture
984
+ (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
850
985
  // Restore terminal
851
986
  if (process.stdin.isTTY) {
852
987
  process.stdin.setRawMode(false);
@@ -859,6 +994,8 @@ async function run(options) {
859
994
  // Cleanup on exit signals
860
995
  const cleanup = () => {
861
996
  (0, state_1.clearAutoClearFlag)();
997
+ stopStreamTailer(); // Stop stream capture
998
+ (0, state_1.unregisterActiveSession)(); // Remove from active sessions registry
862
999
  if (process.stdin.isTTY) {
863
1000
  process.stdin.setRawMode(false);
864
1001
  }
@@ -0,0 +1,19 @@
1
+ /**
2
+ * ekkos stream - Stream capture status and management
3
+ *
4
+ * Shows live status of stream capture for debugging and support.
5
+ */
6
+ interface StreamStatusOptions {
7
+ session?: string;
8
+ watch?: boolean;
9
+ json?: boolean;
10
+ }
11
+ /**
12
+ * Display stream status
13
+ */
14
+ export declare function streamStatus(options: StreamStatusOptions): Promise<void>;
15
+ /**
16
+ * List all sessions with stream data
17
+ */
18
+ export declare function streamList(): void;
19
+ export {};