@sylphx/flow 3.19.0 → 3.20.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 (41) hide show
  1. package/CHANGELOG.md +31 -0
  2. package/assets/agents/builder.md +12 -3
  3. package/package.json +1 -3
  4. package/src/commands/flow/execute-v2.ts +126 -128
  5. package/src/commands/flow-command.ts +52 -42
  6. package/src/commands/hook-command.ts +161 -20
  7. package/src/config/index.ts +0 -20
  8. package/src/core/agent-loader.ts +2 -2
  9. package/src/core/attach-manager.ts +5 -1
  10. package/src/core/cleanup-handler.ts +20 -16
  11. package/src/core/flow-executor.ts +93 -62
  12. package/src/core/functional/index.ts +0 -11
  13. package/src/core/index.ts +1 -1
  14. package/src/core/project-manager.ts +14 -29
  15. package/src/core/secrets-manager.ts +15 -18
  16. package/src/core/session-manager.ts +4 -8
  17. package/src/core/target-manager.ts +6 -3
  18. package/src/core/upgrade-manager.ts +1 -1
  19. package/src/index.ts +1 -1
  20. package/src/services/auto-upgrade.ts +6 -14
  21. package/src/services/config-service.ts +7 -23
  22. package/src/services/index.ts +1 -1
  23. package/src/targets/claude-code.ts +14 -8
  24. package/src/targets/functional/claude-code-logic.ts +11 -7
  25. package/src/targets/opencode.ts +61 -39
  26. package/src/targets/shared/mcp-transforms.ts +20 -43
  27. package/src/types/agent.types.ts +5 -3
  28. package/src/types/mcp.types.ts +38 -1
  29. package/src/types.ts +4 -0
  30. package/src/utils/agent-enhancer.ts +1 -1
  31. package/src/utils/errors.ts +13 -0
  32. package/src/utils/files/file-operations.ts +16 -0
  33. package/src/utils/index.ts +1 -1
  34. package/src/core/error-handling.ts +0 -482
  35. package/src/core/functional/async.ts +0 -101
  36. package/src/core/functional/either.ts +0 -109
  37. package/src/core/functional/error-handler.ts +0 -135
  38. package/src/core/functional/pipe.ts +0 -189
  39. package/src/core/functional/validation.ts +0 -138
  40. package/src/types/mcp-config.types.ts +0 -448
  41. package/src/utils/error-handler.ts +0 -53
@@ -1,27 +1,50 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
- * Hook command - OS notification for Claude Code startup
3
+ * Hook command - Claude Code hook handlers
4
4
  *
5
- * Purpose: Send OS-level notifications when Claude Code starts
5
+ * Purpose: Handle Claude Code lifecycle hooks:
6
+ * - notification: OS-level notification when Claude Code starts
7
+ * - session-start: Load durable memory (MEMORY.md + recent daily logs)
6
8
  *
7
9
  * DESIGN RATIONALE:
8
- * - Simple notification: Just notify user when Claude Code is ready
9
- * - Cross-platform: Supports macOS, Linux, and Windows
10
- * - Non-intrusive: Fails silently if notification system not available
10
+ * - Each hook type returns content to stdout (Claude Code injects as context)
11
+ * - Cross-platform notifications (macOS, Linux, Windows)
12
+ * - SessionStart hook loads cross-session memory at startup
11
13
  */
12
14
 
13
15
  import { exec } from 'node:child_process';
16
+ import { readFile } from 'node:fs/promises';
14
17
  import os from 'node:os';
18
+ import path from 'node:path';
15
19
  import { promisify } from 'node:util';
16
20
  import { Command } from 'commander';
17
21
  import { cli } from '../utils/display/cli-output.js';
18
22
 
23
+ /**
24
+ * Hook input from Claude Code via stdin.
25
+ * Claude Code sends JSON context about the event — we read what we need.
26
+ */
27
+ interface HookInput {
28
+ /** Notification message text */
29
+ message?: string;
30
+ /** Notification title */
31
+ title?: string;
32
+ /** Notification type (permission_prompt, idle_prompt, etc.) */
33
+ notification_type?: string;
34
+ /** How the session started (startup, resume, clear, compact) */
35
+ source?: string;
36
+ /** Current working directory */
37
+ cwd?: string;
38
+ }
39
+
19
40
  const execAsync = promisify(exec);
20
41
 
21
42
  /**
22
- * Hook types supported
43
+ * Hook types supported — single source of truth for both type and runtime validation
23
44
  */
24
- type HookType = 'notification';
45
+ const VALID_HOOK_TYPES = ['notification', 'session-start'] as const;
46
+ type HookType = (typeof VALID_HOOK_TYPES)[number];
47
+ const VALID_HOOK_TYPE_SET = new Set<string>(VALID_HOOK_TYPES);
25
48
 
26
49
  /**
27
50
  * Target platforms supported
@@ -32,18 +55,20 @@ type TargetPlatform = 'claude-code';
32
55
  * Create the hook command
33
56
  */
34
57
  export const hookCommand = new Command('hook')
35
- .description('Load dynamic system information for Claude Code hooks')
36
- .requiredOption('--type <type>', 'Hook type (notification)')
58
+ .description('Handle Claude Code lifecycle hooks (notification, memory)')
59
+ .requiredOption('--type <type>', 'Hook type (notification | session-start)')
37
60
  .option('--target <target>', 'Target platform (claude-code)', 'claude-code')
38
61
  .option('--verbose', 'Show verbose output', false)
39
62
  .action(async (options) => {
40
63
  try {
41
- const hookType = options.type as HookType;
64
+ const hookType = options.type as string;
42
65
  const target = options.target as TargetPlatform;
43
66
 
44
67
  // Validate hook type
45
- if (hookType !== 'notification') {
46
- throw new Error(`Invalid hook type: ${hookType}. Must be 'notification'`);
68
+ if (!VALID_HOOK_TYPE_SET.has(hookType)) {
69
+ throw new Error(
70
+ `Invalid hook type: ${hookType}. Must be one of: ${VALID_HOOK_TYPES.join(', ')}`
71
+ );
47
72
  }
48
73
 
49
74
  // Validate target
@@ -51,8 +76,11 @@ export const hookCommand = new Command('hook')
51
76
  throw new Error(`Invalid target: ${target}. Only 'claude-code' is currently supported`);
52
77
  }
53
78
 
79
+ // Read hook input from stdin (Claude Code passes JSON context)
80
+ const hookInput = await readStdinInput();
81
+
54
82
  // Load and display content based on hook type
55
- const content = await loadHookContent(hookType, target, options.verbose);
83
+ const content = await loadHookContent(hookType as HookType, target, hookInput, options.verbose);
56
84
 
57
85
  // Output the content (no extra formatting, just the content)
58
86
  console.log(content);
@@ -72,27 +100,140 @@ export const hookCommand = new Command('hook')
72
100
  }
73
101
  });
74
102
 
103
+ /**
104
+ * Read JSON input from stdin (non-blocking, returns empty object if no input)
105
+ * Claude Code sends event context as JSON on stdin for all hook types.
106
+ */
107
+ async function readStdinInput(): Promise<HookInput> {
108
+ // stdin is not a TTY when piped from Claude Code
109
+ if (process.stdin.isTTY) {
110
+ return {};
111
+ }
112
+
113
+ return new Promise((resolve) => {
114
+ let data = '';
115
+ process.stdin.setEncoding('utf-8');
116
+ process.stdin.on('data', (chunk) => {
117
+ data += chunk;
118
+ });
119
+ process.stdin.on('end', () => {
120
+ if (!data.trim()) {
121
+ resolve({});
122
+ return;
123
+ }
124
+ try {
125
+ resolve(JSON.parse(data) as HookInput);
126
+ } catch {
127
+ resolve({});
128
+ }
129
+ });
130
+ // Safety timeout — don't hang if stdin never closes
131
+ setTimeout(() => resolve({}), 1000);
132
+ });
133
+ }
134
+
75
135
  /**
76
136
  * Load content for a specific hook type and target
77
137
  */
78
138
  async function loadHookContent(
79
139
  hookType: HookType,
80
140
  _target: TargetPlatform,
141
+ input: HookInput,
81
142
  verbose: boolean = false
82
143
  ): Promise<string> {
83
- if (hookType === 'notification') {
84
- return await sendNotification(verbose);
144
+ switch (hookType) {
145
+ case 'notification':
146
+ return await sendNotification(input, verbose);
147
+ case 'session-start':
148
+ return await loadSessionStartContent(verbose);
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Read a file and return its contents, or empty string if not found
154
+ */
155
+ async function readFileIfExists(filePath: string): Promise<string> {
156
+ try {
157
+ return await readFile(filePath, 'utf-8');
158
+ } catch {
159
+ return '';
160
+ }
161
+ }
162
+
163
+ /**
164
+ * Format a date as YYYY-MM-DD
165
+ */
166
+ function formatDate(date: Date): string {
167
+ const year = date.getFullYear();
168
+ const month = String(date.getMonth() + 1).padStart(2, '0');
169
+ const day = String(date.getDate()).padStart(2, '0');
170
+ return `${year}-${month}-${day}`;
171
+ }
172
+
173
+ /**
174
+ * Load memory files for session start:
175
+ * - MEMORY.md (curated long-term memory)
176
+ * - memory/{today}.md (today's daily log)
177
+ * - memory/{yesterday}.md (yesterday's daily log)
178
+ *
179
+ * Returns concatenated content to stdout for Claude Code context injection
180
+ */
181
+ async function loadSessionStartContent(verbose: boolean): Promise<string> {
182
+ const cwd = process.cwd();
183
+ const sections: string[] = [];
184
+
185
+ // Load MEMORY.md
186
+ const memoryPath = path.join(cwd, 'MEMORY.md');
187
+ const memoryContent = await readFileIfExists(memoryPath);
188
+ if (memoryContent.trim()) {
189
+ sections.push(`## MEMORY.md\n${memoryContent.trim()}`);
190
+ if (verbose) {
191
+ cli.info('Loaded MEMORY.md');
192
+ }
193
+ }
194
+
195
+ // Calculate today and yesterday dates
196
+ const today = new Date();
197
+ const yesterday = new Date(today);
198
+ yesterday.setDate(yesterday.getDate() - 1);
199
+
200
+ const todayStr = formatDate(today);
201
+ const yesterdayStr = formatDate(yesterday);
202
+
203
+ // Load today's daily log
204
+ const todayPath = path.join(cwd, 'memory', `${todayStr}.md`);
205
+ const todayContent = await readFileIfExists(todayPath);
206
+ if (todayContent.trim()) {
207
+ sections.push(`## memory/${todayStr}.md\n${todayContent.trim()}`);
208
+ if (verbose) {
209
+ cli.info(`Loaded memory/${todayStr}.md`);
210
+ }
211
+ }
212
+
213
+ // Load yesterday's daily log
214
+ const yesterdayPath = path.join(cwd, 'memory', `${yesterdayStr}.md`);
215
+ const yesterdayContent = await readFileIfExists(yesterdayPath);
216
+ if (yesterdayContent.trim()) {
217
+ sections.push(`## memory/${yesterdayStr}.md\n${yesterdayContent.trim()}`);
218
+ if (verbose) {
219
+ cli.info(`Loaded memory/${yesterdayStr}.md`);
220
+ }
221
+ }
222
+
223
+ if (verbose && sections.length === 0) {
224
+ cli.info('No memory files found');
85
225
  }
86
226
 
87
- return '';
227
+ return sections.join('\n\n');
88
228
  }
89
229
 
90
230
  /**
91
- * Send OS-level notification
231
+ * Send OS-level notification using event data from Claude Code.
232
+ * Falls back to generic message when stdin input is missing.
92
233
  */
93
- async function sendNotification(verbose: boolean): Promise<string> {
94
- const title = '🔮 Sylphx Flow';
95
- const message = 'Claude Code is ready';
234
+ async function sendNotification(input: HookInput, verbose: boolean): Promise<string> {
235
+ const title = input.title || '🔮 Sylphx Flow';
236
+ const message = input.message || 'Claude Code is ready';
96
237
  const platform = os.platform();
97
238
 
98
239
  if (verbose) {
@@ -1,27 +1,7 @@
1
1
  /**
2
2
  * Configuration modules barrel export
3
- * Centralized access to configuration-related functionality
4
3
  */
5
4
 
6
- // Rules configuration
7
5
  export * from './rules.js';
8
- export {
9
- getDefaultRules,
10
- loadRuleConfiguration,
11
- validateRuleConfiguration,
12
- } from './rules.js';
13
- // MCP server configurations
14
6
  export * from './servers.js';
15
- export {
16
- configureServer,
17
- getServerConfigurations,
18
- validateServerConfiguration,
19
- } from './servers.js';
20
- // Target configurations
21
7
  export * from './targets.js';
22
- // Re-export commonly used configuration functions with better naming
23
- export {
24
- configureTargetDefaults,
25
- getTargetDefaults,
26
- validateTargetConfiguration,
27
- } from './targets.js';
@@ -8,7 +8,7 @@ import { homedir } from 'node:os';
8
8
  import { dirname, join, parse, relative } from 'node:path';
9
9
  import { fileURLToPath } from 'node:url';
10
10
  import matter from 'gray-matter';
11
- import type { Agent, AgentMetadata } from '../types/agent.types.js';
11
+ import type { Agent, AgentDefinition } from '../types/agent.types.js';
12
12
 
13
13
  /**
14
14
  * Load a single agent from a markdown file
@@ -28,7 +28,7 @@ export async function loadAgentFromFile(
28
28
  return null;
29
29
  }
30
30
 
31
- const metadata: AgentMetadata = {
31
+ const metadata: AgentDefinition = {
32
32
  name: data.name,
33
33
  description: data.description || '',
34
34
  };
@@ -10,6 +10,7 @@ import fs from 'node:fs/promises';
10
10
  import path from 'node:path';
11
11
  import { MCP_SERVER_REGISTRY, type MCPServerID } from '../config/servers.js';
12
12
  import { GlobalConfigService } from '../services/global-config.js';
13
+ import type { MCPServerConfigUnion } from '../types/mcp.types.js';
13
14
  import type { Target } from '../types/target.types.js';
14
15
  import { attachItemsToDir, attachRulesFile } from './attach/index.js';
15
16
  import type { BackupManifest } from './backup-manager.js';
@@ -320,7 +321,10 @@ export class AttachManager {
320
321
  // Add Flow MCP servers
321
322
  for (const server of mcpServers) {
322
323
  // Transform the server config for this target
323
- const transformedConfig = target.transformMCPConfig(server.config as any, server.name);
324
+ const transformedConfig = target.transformMCPConfig(
325
+ server.config as MCPServerConfigUnion,
326
+ server.name
327
+ );
324
328
 
325
329
  if (mcpContainer[server.name]) {
326
330
  // Conflict: user has same MCP server
@@ -16,12 +16,15 @@
16
16
  import { existsSync } from 'node:fs';
17
17
  import fs from 'node:fs/promises';
18
18
  import path from 'node:path';
19
+ import createDebug from 'debug';
19
20
  import type { BackupManager } from './backup-manager.js';
20
21
  import type { GitStashManager } from './git-stash-manager.js';
21
22
  import type { ProjectManager } from './project-manager.js';
22
23
  import type { SecretsManager } from './secrets-manager.js';
23
24
  import type { SessionManager } from './session-manager.js';
24
25
 
26
+ const debug = createDebug('flow:cleanup');
27
+
25
28
  export class CleanupHandler {
26
29
  private projectManager: ProjectManager;
27
30
  private sessionManager: SessionManager;
@@ -132,8 +135,8 @@ export class CleanupHandler {
132
135
  await this.gitStashManager.popSettingsChanges(session.projectPath);
133
136
  await this.secretsManager.clearSecrets(this.currentProjectHash);
134
137
  }
135
- } catch (_error) {
136
- // Silent fail - recovery will happen on next startup
138
+ } catch (error) {
139
+ debug('signal cleanup failed:', error);
137
140
  }
138
141
  }
139
142
 
@@ -156,8 +159,8 @@ export class CleanupHandler {
156
159
  await this.gitStashManager.popSettingsChanges(session.projectPath);
157
160
  await this.secretsManager.clearSecrets(projectHash);
158
161
  await this.backupManager.cleanupOldBackups(projectHash, 3);
159
- } catch (_error) {
160
- // Silent fail - don't interrupt startup
162
+ } catch (error) {
163
+ debug('startup recovery failed for session:', error);
161
164
  }
162
165
  }
163
166
 
@@ -168,8 +171,8 @@ export class CleanupHandler {
168
171
  this.sessionManager.cleanupSessionHistory(50),
169
172
  this.cleanupOrphanedProjects(),
170
173
  ]);
171
- } catch {
172
- // Non-fatal
174
+ } catch (error) {
175
+ debug('periodic cleanup failed:', error);
173
176
  }
174
177
  await this.updateCleanupTimestamp();
175
178
  }
@@ -185,7 +188,6 @@ export class CleanupHandler {
185
188
  const hoursSinceLastCleanup = (Date.now() - stat.mtimeMs) / (1000 * 60 * 60);
186
189
  return hoursSinceLastCleanup >= 24;
187
190
  } catch {
188
- // Marker doesn't exist — first run or deleted
189
191
  return true;
190
192
  }
191
193
  }
@@ -197,8 +199,8 @@ export class CleanupHandler {
197
199
  const markerPath = path.join(this.projectManager.getFlowHomeDir(), '.last-cleanup');
198
200
  try {
199
201
  await fs.writeFile(markerPath, new Date().toISOString());
200
- } catch {
201
- // Non-fatal
202
+ } catch (error) {
203
+ debug('failed to update cleanup timestamp:', error);
202
204
  }
203
205
  }
204
206
 
@@ -259,7 +261,8 @@ export class CleanupHandler {
259
261
  try {
260
262
  const entries = await fs.readdir(dir, { withFileTypes: true });
261
263
  return entries.filter((e) => e.isDirectory()).map((e) => e.name);
262
- } catch {
264
+ } catch (error) {
265
+ debug('failed to scan directory %s: %O', dir, error);
263
266
  return [];
264
267
  }
265
268
  };
@@ -292,7 +295,8 @@ export class CleanupHandler {
292
295
  const manifestPath = path.join(paths.backupsDir, sessions[0].name, 'manifest.json');
293
296
  const manifest = JSON.parse(await fs.readFile(manifestPath, 'utf-8'));
294
297
  return manifest.projectPath || null;
295
- } catch {
298
+ } catch (error) {
299
+ debug('failed to read backup manifest for %s: %O', projectHash, error);
296
300
  return null;
297
301
  }
298
302
  }
@@ -306,22 +310,22 @@ export class CleanupHandler {
306
310
  // Remove backups directory
307
311
  try {
308
312
  await fs.rm(paths.backupsDir, { recursive: true, force: true });
309
- } catch {
310
- // Ignore errors
313
+ } catch (error) {
314
+ debug('failed to remove backups for %s: %O', projectHash, error);
311
315
  }
312
316
 
313
317
  // Remove secrets directory
314
318
  try {
315
319
  await fs.rm(paths.secretsDir, { recursive: true, force: true });
316
- } catch {
317
- // Ignore errors
320
+ } catch (error) {
321
+ debug('failed to remove secrets for %s: %O', projectHash, error);
318
322
  }
319
323
 
320
324
  // Remove session file if exists
321
325
  try {
322
326
  await fs.unlink(paths.sessionFile);
323
327
  } catch {
324
- // File might not exist
328
+ // Expected: file may not exist
325
329
  }
326
330
  }
327
331
  }
@@ -8,7 +8,11 @@ import { existsSync } from 'node:fs';
8
8
  import fs from 'node:fs/promises';
9
9
  import path from 'node:path';
10
10
  import chalk from 'chalk';
11
+ import createDebug from 'debug';
11
12
  import type { Target } from '../types/target.types.js';
13
+
14
+ const debug = createDebug('flow:executor');
15
+
12
16
  import { AttachManager } from './attach-manager.js';
13
17
  import { BackupManager } from './backup-manager.js';
14
18
  import { CleanupHandler } from './cleanup-handler.js';
@@ -55,70 +59,62 @@ export class FlowExecutor {
55
59
  }
56
60
 
57
61
  /**
58
- * Execute complete flow with attach mode (with multi-session support)
59
- * Returns summary for caller to display
62
+ * Try to join an existing session. Returns summary if joined, null otherwise.
60
63
  */
61
- async execute(
64
+ private async tryJoinExistingSession(
62
65
  projectPath: string,
63
- options: FlowExecutorOptions = {}
64
- ): Promise<{
65
- joined: boolean;
66
- agents?: number;
67
- commands?: number;
68
- skills?: number;
69
- mcp?: number;
70
- }> {
71
- // Initialize Flow directories
72
- await this.projectManager.initialize();
73
-
74
- // Step 1: Crash recovery on startup
75
- await this.cleanupHandler.recoverOnStartup();
76
-
77
- // Step 2: Get project hash and paths
78
- const projectHash = this.projectManager.getProjectHash(projectPath);
79
- const target = await this.projectManager.detectTarget(projectPath);
80
-
81
- if (options.verbose) {
82
- console.log(chalk.dim(`Project: ${projectPath}`));
83
- console.log(chalk.dim(`Hash: ${projectHash}`));
84
- console.log(chalk.dim(`Target: ${target}\n`));
85
- }
86
-
87
- // Check for existing session
66
+ projectHash: string,
67
+ target: string,
68
+ verbose?: boolean
69
+ ): Promise<{ joined: true } | null> {
88
70
  const existingSession = await this.sessionManager.getActiveSession(projectHash);
71
+ if (!existingSession) {
72
+ return null;
73
+ }
89
74
 
90
- if (existingSession) {
91
- // Verify attached files still exist before joining
92
- const targetObj = resolveTargetOrId(target);
93
- const agentsDir = path.join(projectPath, targetObj.config.agentDir);
94
- const filesExist = existsSync(agentsDir) && (await fs.readdir(agentsDir)).length > 0;
95
-
96
- if (filesExist) {
97
- // Joining existing session - silent
98
- await this.sessionManager.startSession(
99
- projectPath,
100
- projectHash,
101
- target,
102
- existingSession.backupPath
103
- );
104
- this.cleanupHandler.registerCleanupHooks(projectHash);
105
- return { joined: true };
106
- }
75
+ const targetObj = resolveTargetOrId(target);
76
+ const agentsDir = path.join(projectPath, targetObj.config.agentDir);
77
+ const filesExist = existsSync(agentsDir) && (await fs.readdir(agentsDir)).length > 0;
78
+
79
+ if (filesExist) {
80
+ await this.sessionManager.startSession(
81
+ projectPath,
82
+ projectHash,
83
+ target,
84
+ existingSession.backupPath
85
+ );
86
+ this.cleanupHandler.registerCleanupHooks(projectHash);
87
+ return { joined: true };
88
+ }
107
89
 
108
- // Files missing - invalidate session and re-attach
109
- if (options.verbose) {
110
- console.log(chalk.dim('Session files missing, re-attaching...'));
111
- }
112
- await this.sessionManager.endSession(projectHash);
90
+ if (verbose) {
91
+ console.log(chalk.dim('Session files missing, re-attaching...'));
113
92
  }
93
+ await this.sessionManager.endSession(projectHash);
94
+ return null;
95
+ }
114
96
 
115
- // First session — run independent setup steps in parallel
97
+ /**
98
+ * Create a new session: backup, extract secrets, attach templates
99
+ */
100
+ private async createNewSession(
101
+ projectPath: string,
102
+ projectHash: string,
103
+ target: string,
104
+ options: FlowExecutorOptions
105
+ ): Promise<{
106
+ agents: number;
107
+ commands: number;
108
+ skills: number;
109
+ mcp: number;
110
+ }> {
111
+ // Run independent setup steps in parallel
116
112
  await Promise.all([
117
113
  options.skipProjectDocs ? Promise.resolve() : this.ensureProjectDocs(projectPath),
118
114
  this.gitStashManager.stashSettingsChanges(projectPath),
119
115
  ]);
120
116
 
121
- // Backup and extract secrets in parallel (both read project config, neither writes)
117
+ // Backup and extract secrets in parallel
122
118
  const [backup] = await Promise.all([
123
119
  this.backupManager.createBackup(projectPath, projectHash, target),
124
120
  options.skipSecrets
@@ -132,7 +128,6 @@ export class FlowExecutor {
132
128
  }),
133
129
  ]);
134
130
 
135
- // Start session
136
131
  const { session } = await this.sessionManager.startSession(
137
132
  projectPath,
138
133
  projectHash,
@@ -143,7 +138,6 @@ export class FlowExecutor {
143
138
 
144
139
  this.cleanupHandler.registerCleanupHooks(projectHash);
145
140
 
146
- // Clear and attach (silent)
147
141
  if (!options.merge) {
148
142
  await this.clearUserSettings(projectPath, target);
149
143
  }
@@ -157,22 +151,19 @@ export class FlowExecutor {
157
151
 
158
152
  const attachResult = await this.attachManager.attach(projectPath, target, templates, manifest);
159
153
 
160
- // Apply target-specific settings (attribution, hooks, env, thinking mode)
161
- // Non-fatal: CLI can still run without these settings
154
+ // Apply target-specific settings (non-fatal)
162
155
  const targetObj = resolveTargetOrId(target);
163
156
  if (targetObj.applySettings) {
164
157
  try {
165
158
  await targetObj.applySettings(projectPath, {});
166
- } catch {
167
- // Settings are optional — agents/commands/MCP already attached
159
+ } catch (error) {
160
+ debug('applySettings failed:', error);
168
161
  }
169
162
  }
170
163
 
171
164
  await this.backupManager.updateManifest(projectHash, session.sessionId, manifest);
172
165
 
173
- // Return summary for caller to display
174
166
  return {
175
- joined: false,
176
167
  agents: attachResult.agentsAdded.length,
177
168
  commands: attachResult.commandsAdded.length,
178
169
  skills: attachResult.skillsAdded.length,
@@ -180,6 +171,46 @@ export class FlowExecutor {
180
171
  };
181
172
  }
182
173
 
174
+ /**
175
+ * Execute complete flow with attach mode (with multi-session support)
176
+ * Returns summary for caller to display
177
+ */
178
+ async execute(
179
+ projectPath: string,
180
+ options: FlowExecutorOptions = {}
181
+ ): Promise<{
182
+ joined: boolean;
183
+ agents?: number;
184
+ commands?: number;
185
+ skills?: number;
186
+ mcp?: number;
187
+ }> {
188
+ await this.projectManager.initialize();
189
+ await this.cleanupHandler.recoverOnStartup();
190
+
191
+ const projectHash = this.projectManager.getProjectHash(projectPath);
192
+ const target = await this.projectManager.detectTarget(projectPath);
193
+
194
+ if (options.verbose) {
195
+ console.log(chalk.dim(`Project: ${projectPath}`));
196
+ console.log(chalk.dim(`Hash: ${projectHash}`));
197
+ console.log(chalk.dim(`Target: ${target}\n`));
198
+ }
199
+
200
+ const joinResult = await this.tryJoinExistingSession(
201
+ projectPath,
202
+ projectHash,
203
+ target,
204
+ options.verbose
205
+ );
206
+ if (joinResult) {
207
+ return joinResult;
208
+ }
209
+
210
+ const result = await this.createNewSession(projectPath, projectHash, target, options);
211
+ return { joined: false, ...result };
212
+ }
213
+
183
214
  /**
184
215
  * Clear user settings in replace mode
185
216
  * This ensures a clean slate for Flow's configuration
@@ -255,8 +286,8 @@ export class FlowExecutor {
255
286
  if (content.includes('Sylphx Flow') || content.includes('Silent Execution Style')) {
256
287
  await fs.unlink(filePath);
257
288
  }
258
- } catch {
259
- // Ignore errors - file might not be readable
289
+ } catch (error) {
290
+ debug('failed to clean legacy file %s: %O', fileName, error);
260
291
  }
261
292
  }
262
293
  }
@@ -1,19 +1,8 @@
1
1
  /**
2
2
  * Functional programming utilities
3
3
  * Core abstractions for composable, type-safe error handling
4
- *
5
- * PRINCIPLES:
6
- * - Pure functions (no side effects)
7
- * - Explicit error handling (no exceptions in business logic)
8
- * - Composable through map/flatMap
9
- * - Type-safe (leverages TypeScript's type system)
10
4
  */
11
5
 
12
- export * from './async.js';
13
- export * from './either.js';
14
- export * from './error-handler.js';
15
6
  export * from './error-types.js';
16
7
  export * from './option.js';
17
- export * from './pipe.js';
18
8
  export * from './result.js';
19
- export * from './validation.js';
package/src/core/index.ts CHANGED
@@ -3,4 +3,4 @@
3
3
  * Centralized access to core system components
4
4
  */
5
5
 
6
- export { targetManager } from './target-manager';
6
+ export { targetManager } from './target-manager.js';