idea-manager 0.5.0 → 0.6.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.
package/README.md CHANGED
@@ -4,6 +4,8 @@
4
4
 
5
5
  여러 프로젝트를 동시에 진행하는 개발자를 위한 태스크 관리 도구입니다. 아이디어를 서브 프로젝트와 태스크로 조직화하고, 각 태스크별 프롬프트를 정제하여 Claude Code 등 AI 에이전트에게 전달할 수 있습니다. MCP Server를 내장하고 있어 AI 에이전트가 자율적으로 태스크를 가져가 실행할 수 있습니다.
6
6
 
7
+ ![IM Workspace](docs/screenshot.png)
8
+
7
9
  ## 핵심 워크플로우
8
10
 
9
11
  ```
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "idea-manager",
3
- "version": "0.5.0",
3
+ "version": "0.6.1",
4
4
  "description": "AI 기반 브레인스토밍 → 구조화 → 프롬프트 생성 도구. MCP Server 내장.",
5
5
  "keywords": [
6
6
  "brainstorm",
@@ -16,6 +16,7 @@ interface IProject {
16
16
  description: string;
17
17
  project_path: string | null;
18
18
  ai_context: string;
19
+ watch_enabled: boolean;
19
20
  }
20
21
 
21
22
  function WorkspaceInner({ id }: { id: string }) {
@@ -189,9 +190,10 @@ function WorkspaceInner({ id }: { id: string }) {
189
190
  }
190
191
  };
191
192
 
192
- const handleTaskDelete = () => {
193
- if (!selectedTaskId) return;
194
- setConfirmAction({ type: 'delete-task', id: selectedTaskId });
193
+ const handleTaskDelete = (taskId?: string) => {
194
+ const id = taskId || selectedTaskId;
195
+ if (!id) return;
196
+ setConfirmAction({ type: 'delete-task', id });
195
197
  };
196
198
 
197
199
  const handleConfirmAction = async () => {
@@ -236,6 +238,18 @@ function WorkspaceInner({ id }: { id: string }) {
236
238
  }
237
239
  };
238
240
 
241
+ const handleToggleWatch = async () => {
242
+ if (!project) return;
243
+ const res = await fetch(`/api/projects/${id}`, {
244
+ method: 'PUT',
245
+ headers: { 'Content-Type': 'application/json' },
246
+ body: JSON.stringify({ watch_enabled: !project.watch_enabled }),
247
+ });
248
+ if (res.ok) {
249
+ setProject(await res.json());
250
+ }
251
+ };
252
+
239
253
  // Keyboard shortcuts (use e.code for Korean IME compatibility)
240
254
  useEffect(() => {
241
255
  const handler = (e: KeyboardEvent) => {
@@ -304,6 +318,18 @@ function WorkspaceInner({ id }: { id: string }) {
304
318
  )}
305
319
  </div>
306
320
  <div className="flex items-center gap-2">
321
+ <button
322
+ onClick={handleToggleWatch}
323
+ className={`px-3 py-1.5 text-xs border rounded-md transition-colors flex items-center gap-1.5 ${
324
+ project.watch_enabled
325
+ ? 'bg-success/15 text-success border-success/30 hover:bg-success/25'
326
+ : 'bg-muted hover:bg-card-hover text-muted-foreground border-border'
327
+ }`}
328
+ title={project.watch_enabled ? 'Watch ON — submitted 태스크 자동 실행' : 'Watch OFF'}
329
+ >
330
+ <span className={`inline-block w-2 h-2 rounded-full ${project.watch_enabled ? 'bg-success animate-pulse' : 'bg-muted-foreground/40'}`} />
331
+ Watch
332
+ </button>
307
333
  <button
308
334
  onClick={() => setShowAiPolicy(true)}
309
335
  className={`px-3 py-1.5 text-xs border rounded-md transition-colors ${
@@ -391,6 +417,7 @@ function WorkspaceInner({ id }: { id: string }) {
391
417
  onCreateTask={handleCreateTask}
392
418
  onStatusChange={handleTaskStatusChange}
393
419
  onTodayToggle={handleTaskTodayToggle}
420
+ onDeleteTask={handleTaskDelete}
394
421
  />
395
422
  </div>
396
423
 
package/src/cli.ts CHANGED
@@ -38,6 +38,23 @@ program
38
38
  await startMcpServer(ctx);
39
39
  });
40
40
 
41
+ program
42
+ .command('watch')
43
+ .description('Watch for submitted tasks and auto-execute via Claude CLI')
44
+ .option('--project <id>', 'Watch a specific project (default: all)')
45
+ .option('--interval <seconds>', 'Polling interval in seconds', '10')
46
+ .option('--timeout <minutes>', 'Per-task timeout in minutes', '10')
47
+ .option('--dry-run', 'Show what would be executed without running')
48
+ .action(async (opts) => {
49
+ const { startWatcher } = await import('@/lib/watcher');
50
+ await startWatcher({
51
+ projectId: opts.project,
52
+ intervalMs: parseInt(opts.interval) * 1000,
53
+ timeoutMs: parseInt(opts.timeout) * 60000,
54
+ dryRun: !!opts.dryRun,
55
+ });
56
+ });
57
+
41
58
  program
42
59
  .command('start')
43
60
  .description('Start the web UI (Next.js dev server on port 3456)')
@@ -22,6 +22,7 @@ export default function ProjectTree({
22
22
  onCreateTask,
23
23
  onStatusChange,
24
24
  onTodayToggle,
25
+ onDeleteTask,
25
26
  }: {
26
27
  subProjects: ISubProjectWithStats[];
27
28
  tasks: ITask[];
@@ -34,6 +35,7 @@ export default function ProjectTree({
34
35
  onCreateTask: (title: string) => void;
35
36
  onStatusChange: (taskId: string, status: TaskStatus) => void;
36
37
  onTodayToggle: (taskId: string, isToday: boolean) => void;
38
+ onDeleteTask: (taskId: string) => void;
37
39
  }) {
38
40
  const [collapsedSubs, setCollapsedSubs] = useState<Set<string>>(new Set());
39
41
  const [addingTaskFor, setAddingTaskFor] = useState<string | null>(null);
@@ -130,7 +132,7 @@ export default function ProjectTree({
130
132
  <div
131
133
  key={task.id}
132
134
  onClick={() => onSelectTask(task.id)}
133
- className={`flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
135
+ className={`group/task flex items-center gap-1.5 pl-4 pr-2 py-1.5 cursor-pointer transition-colors text-sm border-l-2 ${
134
136
  selectedTaskId === task.id
135
137
  ? 'bg-card-hover border-l-primary'
136
138
  : 'border-l-transparent hover:bg-card-hover/50'
@@ -159,6 +161,17 @@ export default function ProjectTree({
159
161
  *
160
162
  </button>
161
163
  )}
164
+ <button
165
+ onClick={(e) => {
166
+ e.stopPropagation();
167
+ onDeleteTask(task.id);
168
+ }}
169
+ className="flex-shrink-0 text-muted-foreground/0 group-hover/task:text-muted-foreground
170
+ hover:!text-destructive transition-colors text-xs px-0.5"
171
+ title="Delete task"
172
+ >
173
+ ×
174
+ </button>
162
175
  </div>
163
176
  ))}
164
177
 
@@ -1,14 +1,18 @@
1
1
  'use client';
2
2
 
3
3
  import { useState, useEffect, useRef, useCallback } from 'react';
4
- import type { ITaskConversation } from '@/types';
4
+ import type { ITaskConversation, TaskStatus } from '@/types';
5
5
  import ReactMarkdown from 'react-markdown';
6
6
 
7
+ const POLL_INTERVAL = 3000; // Poll every 3s when task is testing
8
+
7
9
  export default function TaskChat({
8
10
  basePath,
11
+ taskStatus,
9
12
  onApplyToPrompt,
10
13
  }: {
11
14
  basePath: string;
15
+ taskStatus?: TaskStatus;
12
16
  onApplyToPrompt: (content: string) => void;
13
17
  }) {
14
18
  const [messages, setMessages] = useState<ITaskConversation[]>([]);
@@ -17,12 +21,26 @@ export default function TaskChat({
17
21
  const messagesEndRef = useRef<HTMLDivElement>(null);
18
22
  const inputRef = useRef<HTMLTextAreaElement>(null);
19
23
 
20
- useEffect(() => {
24
+ const fetchMessages = useCallback(() => {
21
25
  fetch(`${basePath}/chat`)
22
26
  .then(r => r.json())
23
- .then(data => setMessages(Array.isArray(data) ? data : []));
27
+ .then(data => {
28
+ if (Array.isArray(data)) setMessages(data);
29
+ });
24
30
  }, [basePath]);
25
31
 
32
+ // Initial load
33
+ useEffect(() => {
34
+ fetchMessages();
35
+ }, [fetchMessages]);
36
+
37
+ // Auto-poll when task is testing (watcher is running)
38
+ useEffect(() => {
39
+ if (taskStatus !== 'testing') return;
40
+ const interval = setInterval(fetchMessages, POLL_INTERVAL);
41
+ return () => clearInterval(interval);
42
+ }, [taskStatus, fetchMessages]);
43
+
26
44
  useEffect(() => {
27
45
  messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
28
46
  }, [messages]);
@@ -71,6 +89,12 @@ export default function TaskChat({
71
89
  <div className="flex flex-col h-full border-t border-border">
72
90
  <div className="flex items-center justify-between px-3 py-1.5 border-b border-border">
73
91
  <span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">AI Chat</span>
92
+ {taskStatus === 'testing' && (
93
+ <span className="flex items-center gap-1.5 text-xs text-warning">
94
+ <span className="inline-block w-2 h-2 rounded-full bg-warning animate-pulse" />
95
+ Executing...
96
+ </span>
97
+ )}
74
98
  </div>
75
99
 
76
100
  {/* Messages */}
@@ -28,11 +28,16 @@ export default function TaskDetail({
28
28
 
29
29
  const basePath = `/api/projects/${projectId}/sub-projects/${subProjectId}/tasks/${task.id}`;
30
30
 
31
+ // Auto-show chat when task is being executed by watcher
32
+ useEffect(() => {
33
+ if (task.status === 'testing') setShowChat(true);
34
+ }, [task.status]);
35
+
31
36
  // Load prompt
32
37
  useEffect(() => {
33
38
  setTitle(task.title);
34
39
  setDescription(task.description);
35
- setShowChat(false);
40
+ setShowChat(task.status === 'testing');
36
41
  fetch(`${basePath}/prompt`)
37
42
  .then(r => r.json())
38
43
  .then(data => setPromptContent(data.content || ''));
@@ -196,6 +201,7 @@ export default function TaskDetail({
196
201
  <div className="h-[45%] flex-shrink-0">
197
202
  <TaskChat
198
203
  basePath={basePath}
204
+ taskStatus={task.status}
199
205
  onApplyToPrompt={handleApplyToPrompt}
200
206
  />
201
207
  </div>
@@ -17,6 +17,7 @@ export default function TaskList({
17
17
  onCreate,
18
18
  onStatusChange,
19
19
  onTodayToggle,
20
+ onDelete,
20
21
  }: {
21
22
  tasks: ITask[];
22
23
  selectedTaskId: string | null;
@@ -24,6 +25,7 @@ export default function TaskList({
24
25
  onCreate: (title: string) => void;
25
26
  onStatusChange: (taskId: string, status: TaskStatus) => void;
26
27
  onTodayToggle: (taskId: string, isToday: boolean) => void;
28
+ onDelete: (taskId: string) => void;
27
29
  }) {
28
30
  const [newTitle, setNewTitle] = useState('');
29
31
  const [adding, setAdding] = useState(false);
@@ -48,7 +50,7 @@ export default function TaskList({
48
50
  <div
49
51
  key={task.id}
50
52
  onClick={() => onSelect(task.id)}
51
- className={`flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
53
+ className={`group flex items-center gap-2 px-3 py-2 cursor-pointer transition-colors text-sm border-l-2 ${
52
54
  selectedTaskId === task.id
53
55
  ? 'bg-card-hover border-l-primary'
54
56
  : 'border-l-transparent hover:bg-card-hover/50'
@@ -77,6 +79,17 @@ export default function TaskList({
77
79
  *
78
80
  </button>
79
81
  )}
82
+ <button
83
+ onClick={(e) => {
84
+ e.stopPropagation();
85
+ onDelete(task.id);
86
+ }}
87
+ className="flex-shrink-0 text-muted-foreground/0 group-hover:text-muted-foreground
88
+ hover:!text-destructive transition-colors text-xs px-0.5"
89
+ title="Delete task"
90
+ >
91
+ ×
92
+ </button>
80
93
  </div>
81
94
  ))}
82
95
  </div>
@@ -8,11 +8,16 @@ const MAX_TURNS = 80;
8
8
  export type OnTextChunk = (text: string) => void;
9
9
  export type OnRawEvent = (event: Record<string, unknown>) => void;
10
10
 
11
+ export interface RunClaudeOptions {
12
+ cwd?: string;
13
+ timeoutMs?: number;
14
+ }
15
+
11
16
  /**
12
17
  * Spawn Claude Code CLI and collect the result text.
13
18
  * Optional onText callback receives streaming text chunks as they arrive.
14
19
  */
15
- export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent): Promise<string> {
20
+ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnRawEvent, options?: RunClaudeOptions): Promise<string> {
16
21
  return new Promise((resolve, reject) => {
17
22
  const useStreamJson = !!(onText || onRawEvent);
18
23
  const args = [
@@ -35,11 +40,21 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
35
40
  }
36
41
 
37
42
  const proc = spawn(CLI_PATH, args, {
38
- cwd: process.cwd(),
43
+ cwd: options?.cwd || process.cwd(),
39
44
  stdio: ['pipe', 'pipe', 'pipe'],
40
45
  env: { ...cleanEnv, FORCE_COLOR: '0' },
41
46
  });
42
47
 
48
+ // Timeout handling
49
+ let timeoutTimer: ReturnType<typeof setTimeout> | null = null;
50
+ let timedOut = false;
51
+ if (options?.timeoutMs) {
52
+ timeoutTimer = setTimeout(() => {
53
+ timedOut = true;
54
+ proc.kill('SIGTERM');
55
+ }, options.timeoutMs);
56
+ }
57
+
43
58
  // Write prompt to stdin and close it
44
59
  proc.stdin?.write(prompt);
45
60
  proc.stdin?.end();
@@ -99,10 +114,15 @@ export function runClaude(prompt: string, onText?: OnTextChunk, onRawEvent?: OnR
99
114
  });
100
115
 
101
116
  proc.on('exit', (code, signal) => {
117
+ if (timeoutTimer) clearTimeout(timeoutTimer);
102
118
  // Clean up known CLI noise from text output
103
119
  if (!useStreamJson) {
104
120
  resultText = resultText.replace(/Error: Reached max turns \(\d+\)\s*/g, '').trim();
105
121
  }
122
+ if (timedOut) {
123
+ reject(new Error(`Claude CLI timed out after ${Math.round((options?.timeoutMs || 0) / 1000)}s`));
124
+ return;
125
+ }
106
126
  if (code !== 0 && !resultText) {
107
127
  const detail = stderrText.slice(0, 500) || (signal ? `killed by signal ${signal}` : 'no output');
108
128
  reject(new Error(`Claude CLI exited with code ${code}: ${detail}`));
@@ -2,14 +2,31 @@ import { getDb } from '../index';
2
2
  import { generateId } from '../../utils/id';
3
3
  import type { IProject } from '@/types';
4
4
 
5
+ interface ProjectRow {
6
+ id: string;
7
+ name: string;
8
+ description: string;
9
+ project_path: string | null;
10
+ ai_context: string;
11
+ watch_enabled: number;
12
+ created_at: string;
13
+ updated_at: string;
14
+ }
15
+
16
+ function rowToProject(row: ProjectRow): IProject {
17
+ return { ...row, watch_enabled: row.watch_enabled === 1 };
18
+ }
19
+
5
20
  export function listProjects(): IProject[] {
6
21
  const db = getDb();
7
- return db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as IProject[];
22
+ const rows = db.prepare('SELECT * FROM projects ORDER BY updated_at DESC').all() as ProjectRow[];
23
+ return rows.map(rowToProject);
8
24
  }
9
25
 
10
26
  export function getProject(id: string): IProject | undefined {
11
27
  const db = getDb();
12
- return db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as IProject | undefined;
28
+ const row = db.prepare('SELECT * FROM projects WHERE id = ?').get(id) as ProjectRow | undefined;
29
+ return row ? rowToProject(row) : undefined;
13
30
  }
14
31
 
15
32
  export function createProject(name: string, description: string = '', projectPath?: string): IProject {
@@ -30,7 +47,7 @@ export function createProject(name: string, description: string = '', projectPat
30
47
  return getProject(id)!;
31
48
  }
32
49
 
33
- export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string }): IProject | undefined {
50
+ export function updateProject(id: string, data: { name?: string; description?: string; project_path?: string | null; ai_context?: string; watch_enabled?: boolean }): IProject | undefined {
34
51
  const db = getDb();
35
52
  const project = getProject(id);
36
53
  if (!project) return undefined;
@@ -38,12 +55,13 @@ export function updateProject(id: string, data: { name?: string; description?: s
38
55
  const now = new Date().toISOString();
39
56
 
40
57
  db.prepare(
41
- 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, updated_at = ? WHERE id = ?'
58
+ 'UPDATE projects SET name = ?, description = ?, project_path = ?, ai_context = ?, watch_enabled = ?, updated_at = ? WHERE id = ?'
42
59
  ).run(
43
60
  data.name ?? project.name,
44
61
  data.description ?? project.description,
45
62
  data.project_path !== undefined ? data.project_path : project.project_path,
46
63
  data.ai_context !== undefined ? data.ai_context : (project.ai_context ?? ''),
64
+ data.watch_enabled !== undefined ? (data.watch_enabled ? 1 : 0) : (project.watch_enabled ? 1 : 0),
47
65
  now,
48
66
  id,
49
67
  );
@@ -30,6 +30,9 @@ export function initSchema(db: Database.Database): void {
30
30
  if (!projCols.some(c => c.name === 'ai_context')) {
31
31
  db.exec("ALTER TABLE projects ADD COLUMN ai_context TEXT NOT NULL DEFAULT ''");
32
32
  }
33
+ if (!projCols.some(c => c.name === 'watch_enabled')) {
34
+ db.exec("ALTER TABLE projects ADD COLUMN watch_enabled INTEGER NOT NULL DEFAULT 0");
35
+ }
33
36
 
34
37
  // v2 tables
35
38
  db.exec(`
@@ -0,0 +1,236 @@
1
+ import fs from 'fs';
2
+ import { runClaude } from './ai/client';
3
+ import { listProjects, getProject } from './db/queries/projects';
4
+ import { getSubProject } from './db/queries/sub-projects';
5
+ import { getTasksByProject, getTask, updateTask } from './db/queries/tasks';
6
+ import { getTaskPrompt } from './db/queries/task-prompts';
7
+ import { addTaskConversation } from './db/queries/task-conversations';
8
+ import type { ITask, IProject } from '@/types';
9
+
10
+ export interface WatcherOptions {
11
+ projectId?: string;
12
+ intervalMs: number;
13
+ timeoutMs: number;
14
+ dryRun: boolean;
15
+ }
16
+
17
+ const PROGRESS_SAVE_INTERVAL = 5000; // Save streaming output to DB every 5s
18
+
19
+ function timestamp(): string {
20
+ return new Date().toLocaleTimeString('ko-KR', { hour12: false });
21
+ }
22
+
23
+ function log(msg: string) {
24
+ console.log(`[IM Watch] ${msg}`);
25
+ }
26
+
27
+ function logTask(msg: string) {
28
+ console.log(` ${msg}`);
29
+ }
30
+
31
+ function formatDuration(ms: number): string {
32
+ const s = Math.floor(ms / 1000);
33
+ if (s < 60) return `${s}s`;
34
+ const m = Math.floor(s / 60);
35
+ const rem = s % 60;
36
+ return `${m}m ${rem}s`;
37
+ }
38
+
39
+ function resolveCwd(task: ITask, project: IProject): string | null {
40
+ const subProject = getSubProject(task.sub_project_id);
41
+ if (subProject?.folder_path && fs.existsSync(subProject.folder_path)) {
42
+ return subProject.folder_path;
43
+ }
44
+ if (project.project_path && fs.existsSync(project.project_path)) {
45
+ return project.project_path;
46
+ }
47
+ return null;
48
+ }
49
+
50
+ async function executeTask(task: ITask, project: IProject, options: WatcherOptions): Promise<void> {
51
+ const cwd = resolveCwd(task, project);
52
+ if (!cwd) {
53
+ logTask(`⚠ Skip "${task.title}" — no folder_path or project_path set`);
54
+ return;
55
+ }
56
+
57
+ const prompt = getTaskPrompt(task.id);
58
+ if (!prompt?.content?.trim()) {
59
+ logTask(`⚠ Skip "${task.title}" — no prompt content`);
60
+ return;
61
+ }
62
+
63
+ const fresh = getTask(task.id);
64
+ if (!fresh || fresh.status !== 'submitted') {
65
+ logTask(`⚠ Skip "${task.title}" — status already changed`);
66
+ return;
67
+ }
68
+
69
+ const subProject = getSubProject(task.sub_project_id);
70
+ const subName = subProject?.name || 'unknown';
71
+
72
+ console.log(`[${timestamp()}] ▶ "${task.title}" (sub: ${subName}, cwd: ${cwd})`);
73
+
74
+ if (options.dryRun) {
75
+ logTask(`[DRY RUN] Would execute prompt (${prompt.content.length} chars)`);
76
+ return;
77
+ }
78
+
79
+ // Transition: submitted → testing
80
+ updateTask(task.id, { status: 'testing' });
81
+ logTask(`submitted → testing`);
82
+ addTaskConversation(task.id, 'user', `[watch] 실행 시작`);
83
+
84
+ const startTime = Date.now();
85
+
86
+ let fullPrompt = prompt.content;
87
+ if (project.ai_context) {
88
+ fullPrompt = `Project AI Policy:\n${project.ai_context}\n\n---\n\n${fullPrompt}`;
89
+ }
90
+
91
+ logTask('────────────────────────────────');
92
+
93
+ // Accumulate streaming output and periodically save to DB
94
+ let accumulated = '';
95
+ let progressMsgId: string | null = null;
96
+ let lastSaveTime = Date.now();
97
+
98
+ const saveProgress = () => {
99
+ if (!accumulated.trim()) return;
100
+ const content = `[진행 중]\n${accumulated}`;
101
+ if (progressMsgId) {
102
+ // Update existing progress message
103
+ const { getDb } = require('./db/index');
104
+ const db = getDb();
105
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(content, progressMsgId);
106
+ } else {
107
+ const msg = addTaskConversation(task.id, 'assistant', content);
108
+ progressMsgId = msg.id;
109
+ }
110
+ };
111
+
112
+ try {
113
+ const onText = (chunk: string) => {
114
+ process.stdout.write(chunk);
115
+ accumulated += chunk;
116
+
117
+ // Save to DB periodically
118
+ if (Date.now() - lastSaveTime > PROGRESS_SAVE_INTERVAL) {
119
+ saveProgress();
120
+ lastSaveTime = Date.now();
121
+ }
122
+ };
123
+
124
+ const result = await runClaude(fullPrompt, onText, undefined, {
125
+ cwd,
126
+ timeoutMs: options.timeoutMs,
127
+ });
128
+
129
+ process.stdout.write('\n');
130
+ logTask('────────────────────────────────');
131
+
132
+ const duration = Date.now() - startTime;
133
+ updateTask(task.id, { status: 'done' });
134
+
135
+ // Replace progress message with final result
136
+ if (progressMsgId) {
137
+ const { getDb } = require('./db/index');
138
+ const db = getDb();
139
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(result || '(no output)', progressMsgId);
140
+ } else {
141
+ addTaskConversation(task.id, 'assistant', result || '(no output)');
142
+ }
143
+
144
+ console.log(`[${timestamp()}] ✓ Done (${formatDuration(duration)})`);
145
+ } catch (err) {
146
+ const duration = Date.now() - startTime;
147
+ process.stdout.write('\n');
148
+ logTask('────────────────────────────────');
149
+ const errorMsg = err instanceof Error ? err.message : String(err);
150
+ updateTask(task.id, { status: 'problem' });
151
+
152
+ // Replace progress message with error
153
+ if (progressMsgId) {
154
+ const { getDb } = require('./db/index');
155
+ const db = getDb();
156
+ db.prepare('UPDATE task_conversations SET content = ? WHERE id = ?').run(`[error] ${errorMsg}`, progressMsgId);
157
+ } else {
158
+ addTaskConversation(task.id, 'assistant', `[error] ${errorMsg}`);
159
+ }
160
+
161
+ console.log(`[${timestamp()}] ✗ Failed (${formatDuration(duration)}): ${errorMsg}`);
162
+ }
163
+ }
164
+
165
+ export async function startWatcher(options: WatcherOptions): Promise<void> {
166
+ if (options.projectId) {
167
+ const project = getProject(options.projectId);
168
+ if (!project) {
169
+ console.error(`Error: Project "${options.projectId}" not found.`);
170
+ process.exit(1);
171
+ }
172
+ log(`Watching project: "${project.name}" (${project.id})`);
173
+ } else {
174
+ log(`Watching all watch-enabled projects`);
175
+ }
176
+
177
+ log(`Polling every ${options.intervalMs / 1000}s | Timeout: ${options.timeoutMs / 60000}m${options.dryRun ? ' | DRY RUN' : ''}`);
178
+ log('─'.repeat(50));
179
+ log('Press Ctrl+C to stop\n');
180
+
181
+ let isProcessing = false;
182
+ let shuttingDown = false;
183
+
184
+ const poll = async () => {
185
+ if (isProcessing || shuttingDown) return;
186
+ isProcessing = true;
187
+
188
+ try {
189
+ // Only process watch-enabled projects
190
+ const projects = options.projectId
191
+ ? [getProject(options.projectId)!].filter(p => p)
192
+ : listProjects().filter(p => p.watch_enabled);
193
+
194
+ const submittedTasks: { task: ITask; project: IProject }[] = [];
195
+
196
+ for (const project of projects) {
197
+ const tasks = getTasksByProject(project.id).filter(t => t.status === 'submitted');
198
+ for (const task of tasks) {
199
+ submittedTasks.push({ task, project });
200
+ }
201
+ }
202
+
203
+ if (submittedTasks.length > 0) {
204
+ console.log(`[${timestamp()}] Found ${submittedTasks.length} submitted task(s)`);
205
+ for (const { task, project } of submittedTasks) {
206
+ if (shuttingDown) break;
207
+ await executeTask(task, project, options);
208
+ }
209
+ }
210
+ } catch (err) {
211
+ const msg = err instanceof Error ? err.message : String(err);
212
+ console.error(`[${timestamp()}] Poll error: ${msg}`);
213
+ } finally {
214
+ isProcessing = false;
215
+ }
216
+ };
217
+
218
+ const shutdown = () => {
219
+ if (shuttingDown) return;
220
+ shuttingDown = true;
221
+ console.log(`\n[IM Watch] Shutting down...${isProcessing ? ' (waiting for current task)' : ''}`);
222
+ if (!isProcessing) process.exit(0);
223
+ const checkDone = setInterval(() => {
224
+ if (!isProcessing) {
225
+ clearInterval(checkDone);
226
+ process.exit(0);
227
+ }
228
+ }, 500);
229
+ };
230
+
231
+ process.on('SIGINT', shutdown);
232
+ process.on('SIGTERM', shutdown);
233
+
234
+ await poll();
235
+ setInterval(poll, options.intervalMs);
236
+ }
@@ -4,6 +4,7 @@ export interface IProject {
4
4
  description: string;
5
5
  project_path: string | null;
6
6
  ai_context: string;
7
+ watch_enabled: boolean;
7
8
  created_at: string;
8
9
  updated_at: string;
9
10
  }