@siftd/connect-agent 0.2.37 → 0.2.38

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,50 @@
1
+ /**
2
+ * Calendar + Todo Tools
3
+ *
4
+ * Safe, dedicated writers for Lia-managed JSON data used by /cal and /todo.
5
+ */
6
+ import type { ToolResult } from './bash.js';
7
+ type CalendarDef = {
8
+ id: string;
9
+ name: string;
10
+ color?: string;
11
+ };
12
+ type CalendarEventInput = {
13
+ id?: string;
14
+ title: string;
15
+ date?: string;
16
+ startTime?: string;
17
+ endTime?: string;
18
+ calendarId?: string;
19
+ notes?: string;
20
+ };
21
+ type TodoPriority = 'low' | 'medium' | 'high';
22
+ type TodoSubtaskInput = {
23
+ id?: string;
24
+ title: string;
25
+ done?: boolean;
26
+ notes?: string;
27
+ };
28
+ type TodoItemInput = {
29
+ id?: string;
30
+ title: string;
31
+ done?: boolean;
32
+ due?: string;
33
+ priority?: TodoPriority;
34
+ notes?: string;
35
+ subtasks?: TodoSubtaskInput[];
36
+ };
37
+ export declare class CalendarTools {
38
+ private calendarPath;
39
+ private todoPath;
40
+ constructor();
41
+ upsertCalendarEvents(inputEvents: CalendarEventInput[], inputCalendars?: CalendarDef[]): ToolResult;
42
+ upsertTodoItems(inputItems: TodoItemInput[]): ToolResult;
43
+ private readCalendarPayload;
44
+ private normalizeCalendars;
45
+ private normalizeEvents;
46
+ private eventDedupeKey;
47
+ private readTodoPayload;
48
+ private normalizeTodoItems;
49
+ }
50
+ export {};
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Calendar + Todo Tools
3
+ *
4
+ * Safe, dedicated writers for Lia-managed JSON data used by /cal and /todo.
5
+ */
6
+ import { existsSync, mkdirSync, readFileSync, renameSync, writeFileSync } from 'fs';
7
+ import { join } from 'path';
8
+ import { ensureLiaHub, getLiaHubPaths } from '../core/hub.js';
9
+ import { createHash } from 'crypto';
10
+ const DEFAULT_CALENDARS = [
11
+ { id: 'primary', name: 'Primary', color: '#8b5cf6' },
12
+ { id: 'work', name: 'Work', color: '#38bdf8' },
13
+ { id: 'research', name: 'Research', color: '#22c55e' },
14
+ ];
15
+ function isDateKey(value) {
16
+ return /^\d{4}-\d{2}-\d{2}$/.test(value);
17
+ }
18
+ function isTimeKey(value) {
19
+ return /^\d{2}:\d{2}$/.test(value);
20
+ }
21
+ function parseIsoDate(value) {
22
+ const parsed = new Date(value);
23
+ if (Number.isNaN(parsed.getTime()))
24
+ return null;
25
+ return parsed;
26
+ }
27
+ function toDateKey(date) {
28
+ const year = date.getFullYear();
29
+ const month = `${date.getMonth() + 1}`.padStart(2, '0');
30
+ const day = `${date.getDate()}`.padStart(2, '0');
31
+ return `${year}-${month}-${day}`;
32
+ }
33
+ function toTimeKey(date) {
34
+ const hour = `${date.getHours()}`.padStart(2, '0');
35
+ const minute = `${date.getMinutes()}`.padStart(2, '0');
36
+ return `${hour}:${minute}`;
37
+ }
38
+ function normalizeTime(value) {
39
+ if (!value || typeof value !== 'string')
40
+ return {};
41
+ const trimmed = value.trim();
42
+ if (!trimmed)
43
+ return {};
44
+ if (isTimeKey(trimmed))
45
+ return { time: trimmed };
46
+ if (trimmed.includes('T')) {
47
+ const parsed = parseIsoDate(trimmed);
48
+ if (!parsed)
49
+ return {};
50
+ return { date: toDateKey(parsed), time: toTimeKey(parsed) };
51
+ }
52
+ return {};
53
+ }
54
+ function stableId(namespace, parts) {
55
+ const key = parts.filter(Boolean).join('|');
56
+ const hash = createHash('sha1').update(`${namespace}|${key}`).digest('hex').slice(0, 12);
57
+ return `${namespace}-${hash}`;
58
+ }
59
+ function safeParseJson(raw) {
60
+ try {
61
+ return JSON.parse(raw);
62
+ }
63
+ catch {
64
+ return null;
65
+ }
66
+ }
67
+ function writeFileAtomic(path, content) {
68
+ const tmp = `${path}.tmp-${process.pid}-${Date.now()}`;
69
+ writeFileSync(tmp, content, 'utf-8');
70
+ renameSync(tmp, path);
71
+ }
72
+ export class CalendarTools {
73
+ calendarPath;
74
+ todoPath;
75
+ constructor() {
76
+ const outputs = getLiaHubPaths().outputs;
77
+ this.calendarPath = join(outputs, '.lia', 'calendar.json');
78
+ this.todoPath = join(outputs, '.lia', 'todos.json');
79
+ }
80
+ upsertCalendarEvents(inputEvents, inputCalendars) {
81
+ try {
82
+ ensureLiaHub();
83
+ const stateDir = join(getLiaHubPaths().outputs, '.lia');
84
+ if (!existsSync(stateDir))
85
+ mkdirSync(stateDir, { recursive: true });
86
+ const existing = this.readCalendarPayload();
87
+ const calendars = this.normalizeCalendars(inputCalendars || existing.calendars);
88
+ const events = this.normalizeEvents(inputEvents, calendars, existing.events);
89
+ const payload = {
90
+ version: 1,
91
+ calendars,
92
+ events,
93
+ };
94
+ writeFileAtomic(this.calendarPath, JSON.stringify(payload, null, 2) + '\n');
95
+ return { success: true, output: `Calendar updated: ${inputEvents.length} upsert request(s), ${events.length} total event(s).` };
96
+ }
97
+ catch (error) {
98
+ return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
99
+ }
100
+ }
101
+ upsertTodoItems(inputItems) {
102
+ try {
103
+ ensureLiaHub();
104
+ const stateDir = join(getLiaHubPaths().outputs, '.lia');
105
+ if (!existsSync(stateDir))
106
+ mkdirSync(stateDir, { recursive: true });
107
+ const existing = this.readTodoPayload();
108
+ const items = this.normalizeTodoItems(inputItems, existing.items);
109
+ const payload = { version: 1, items };
110
+ writeFileAtomic(this.todoPath, JSON.stringify(payload, null, 2) + '\n');
111
+ return { success: true, output: `Todo updated: ${inputItems.length} upsert request(s), ${items.length} total item(s).` };
112
+ }
113
+ catch (error) {
114
+ return { success: false, output: '', error: error instanceof Error ? error.message : String(error) };
115
+ }
116
+ }
117
+ readCalendarPayload() {
118
+ if (!existsSync(this.calendarPath)) {
119
+ return { version: 1, calendars: DEFAULT_CALENDARS, events: [] };
120
+ }
121
+ const raw = readFileSync(this.calendarPath, 'utf-8');
122
+ const parsed = safeParseJson(raw);
123
+ const calendars = this.normalizeCalendars(parsed?.calendars);
124
+ const events = Array.isArray(parsed?.events) ? parsed?.events : [];
125
+ return { version: 1, calendars, events };
126
+ }
127
+ normalizeCalendars(calendars) {
128
+ if (!Array.isArray(calendars) || calendars.length === 0)
129
+ return DEFAULT_CALENDARS;
130
+ const normalized = calendars
131
+ .filter((cal) => Boolean(cal) && typeof cal.id === 'string' && typeof cal.name === 'string')
132
+ .map((cal, index) => ({
133
+ id: cal.id,
134
+ name: cal.name,
135
+ color: typeof cal.color === 'string' && cal.color ? cal.color : DEFAULT_CALENDARS[index % DEFAULT_CALENDARS.length].color,
136
+ }));
137
+ return normalized.length > 0 ? normalized : DEFAULT_CALENDARS;
138
+ }
139
+ normalizeEvents(inputEvents, calendars, existingEvents) {
140
+ const existingById = new Map(existingEvents.map((event) => [event.id, event]));
141
+ const seenKey = new Set(existingEvents.map((event) => this.eventDedupeKey(event)));
142
+ const fallbackCalendarId = calendars[0]?.id || 'primary';
143
+ for (const rawEvent of inputEvents) {
144
+ if (!rawEvent || typeof rawEvent.title !== 'string' || !rawEvent.title.trim())
145
+ continue;
146
+ const title = rawEvent.title.trim();
147
+ let date = typeof rawEvent.date === 'string' && isDateKey(rawEvent.date.trim()) ? rawEvent.date.trim() : undefined;
148
+ const startNorm = normalizeTime(rawEvent.startTime);
149
+ const endNorm = normalizeTime(rawEvent.endTime);
150
+ if (!date)
151
+ date = startNorm.date || endNorm.date;
152
+ if (!date)
153
+ date = toDateKey(new Date());
154
+ const startTime = startNorm.time || (typeof rawEvent.startTime === 'string' && isTimeKey(rawEvent.startTime.trim()) ? rawEvent.startTime.trim() : undefined);
155
+ const endTime = endNorm.time || (typeof rawEvent.endTime === 'string' && isTimeKey(rawEvent.endTime.trim()) ? rawEvent.endTime.trim() : undefined);
156
+ const calendarId = typeof rawEvent.calendarId === 'string' && rawEvent.calendarId.trim()
157
+ ? rawEvent.calendarId.trim()
158
+ : fallbackCalendarId;
159
+ const notes = typeof rawEvent.notes === 'string' && rawEvent.notes.trim() ? rawEvent.notes.trim() : undefined;
160
+ const id = typeof rawEvent.id === 'string' && rawEvent.id.trim()
161
+ ? rawEvent.id.trim()
162
+ : stableId('event', [title, date, startTime, endTime, calendarId]);
163
+ const event = { id, title, date, startTime, endTime, calendarId, notes };
164
+ const key = this.eventDedupeKey(event);
165
+ if (seenKey.has(key) && !existingById.has(id))
166
+ continue;
167
+ seenKey.add(key);
168
+ existingById.set(id, event);
169
+ }
170
+ return Array.from(existingById.values()).sort((a, b) => {
171
+ const dateCmp = a.date.localeCompare(b.date);
172
+ if (dateCmp !== 0)
173
+ return dateCmp;
174
+ return (a.startTime || '').localeCompare(b.startTime || '');
175
+ });
176
+ }
177
+ eventDedupeKey(event) {
178
+ return [event.title, event.date, event.startTime || '', event.endTime || '', event.calendarId].join('|').toLowerCase();
179
+ }
180
+ readTodoPayload() {
181
+ if (!existsSync(this.todoPath)) {
182
+ return { version: 1, items: [] };
183
+ }
184
+ const raw = readFileSync(this.todoPath, 'utf-8');
185
+ const parsed = safeParseJson(raw);
186
+ const items = Array.isArray(parsed?.items) ? parsed.items : [];
187
+ return { version: 1, items };
188
+ }
189
+ normalizeTodoItems(inputItems, existingItems) {
190
+ const existingById = new Map(existingItems.map((item) => [item.id, item]));
191
+ const seenKey = new Set(existingItems.map((item) => item.title.trim().toLowerCase()));
192
+ for (const rawItem of inputItems) {
193
+ if (!rawItem || typeof rawItem.title !== 'string' || !rawItem.title.trim())
194
+ continue;
195
+ const title = rawItem.title.trim();
196
+ const id = typeof rawItem.id === 'string' && rawItem.id.trim()
197
+ ? rawItem.id.trim()
198
+ : stableId('todo', [title, rawItem.due]);
199
+ const priority = rawItem.priority === 'low' || rawItem.priority === 'high' ? rawItem.priority : 'medium';
200
+ const due = typeof rawItem.due === 'string' && rawItem.due.trim() ? rawItem.due.trim() : undefined;
201
+ const notes = typeof rawItem.notes === 'string' && rawItem.notes.trim() ? rawItem.notes.trim() : undefined;
202
+ const incomingSubtasks = Array.isArray(rawItem.subtasks)
203
+ ? rawItem.subtasks
204
+ .filter((subtask) => subtask && typeof subtask.title === 'string' && subtask.title.trim())
205
+ .map((subtask, index) => ({
206
+ id: typeof subtask.id === 'string' && subtask.id.trim()
207
+ ? subtask.id.trim()
208
+ : stableId('subtask', [id, subtask.title.trim(), String(index)]),
209
+ title: subtask.title.trim(),
210
+ done: Boolean(subtask.done),
211
+ notes: typeof subtask.notes === 'string' && subtask.notes.trim() ? subtask.notes.trim() : undefined,
212
+ }))
213
+ : undefined;
214
+ const previous = existingById.get(id);
215
+ const subtasks = incomingSubtasks || previous?.subtasks;
216
+ let done = Boolean(rawItem.done);
217
+ if (incomingSubtasks && incomingSubtasks.length > 0) {
218
+ done = incomingSubtasks.every((subtask) => subtask.done);
219
+ }
220
+ else if (done && Array.isArray(previous?.subtasks) && previous.subtasks.length > 0) {
221
+ // Marking a parent done should also complete subtasks.
222
+ for (const subtask of previous.subtasks)
223
+ subtask.done = true;
224
+ }
225
+ const key = title.toLowerCase();
226
+ if (seenKey.has(key) && !existingById.has(id))
227
+ continue;
228
+ seenKey.add(key);
229
+ existingById.set(id, { id, title, done, due, priority, notes, subtasks });
230
+ }
231
+ return Array.from(existingById.values());
232
+ }
233
+ }
@@ -2,9 +2,10 @@
2
2
  * Worker Tools
3
3
  * Tools for spawning and managing Claude Code workers
4
4
  */
5
- import { GalleryCallback, GalleryWorker } from '../workers/manager.js';
5
+ import { GalleryCallback, GalleryWorker, WorkerLogCallback } from '../workers/manager.js';
6
6
  import type { ToolResult } from './bash.js';
7
7
  export { GalleryCallback, GalleryWorker };
8
+ export type { WorkerLogCallback };
8
9
  export declare class WorkerTools {
9
10
  private manager;
10
11
  constructor(workspaceDir: string);
@@ -12,6 +13,10 @@ export declare class WorkerTools {
12
13
  * Set callback for gallery updates (worker assets for UI)
13
14
  */
14
15
  setGalleryCallback(callback: GalleryCallback | null): void;
16
+ /**
17
+ * Set callback for worker log streaming (stdout/stderr)
18
+ */
19
+ setLogCallback(callback: WorkerLogCallback | null): void;
15
20
  /**
16
21
  * Get gallery workers with assets
17
22
  */
@@ -14,6 +14,12 @@ export class WorkerTools {
14
14
  setGalleryCallback(callback) {
15
15
  this.manager.setGalleryCallback(callback);
16
16
  }
17
+ /**
18
+ * Set callback for worker log streaming (stdout/stderr)
19
+ */
20
+ setLogCallback(callback) {
21
+ this.manager.setLogCallback(callback);
22
+ }
17
23
  /**
18
24
  * Get gallery workers with assets
19
25
  */
@@ -9,16 +9,23 @@ export interface GalleryWorker {
9
9
  status: 'running' | 'completed' | 'failed';
10
10
  assets: WorkerAsset[];
11
11
  }
12
+ export type WorkerLogCallback = (workerId: string, line: string, stream: 'stdout' | 'stderr') => void;
12
13
  export type GalleryCallback = (workers: GalleryWorker[]) => void;
13
14
  export declare class WorkerManager {
14
15
  private config;
15
16
  private activeWorkers;
16
17
  private galleryCallback;
18
+ private workerLogCallback;
17
19
  constructor(workspaceDir: string, configOverrides?: Partial<WorkerConfig>);
18
20
  /**
19
21
  * Set callback for gallery updates (worker assets for UI)
20
22
  */
21
23
  setGalleryCallback(callback: GalleryCallback | null): void;
24
+ /**
25
+ * Set callback for worker log streaming (stdout/stderr)
26
+ */
27
+ setLogCallback(callback: WorkerLogCallback | null): void;
28
+ private emitWorkerLog;
22
29
  /**
23
30
  * Get gallery workers with assets for UI
24
31
  */
@@ -52,6 +52,7 @@ export class WorkerManager {
52
52
  config;
53
53
  activeWorkers = new Map();
54
54
  galleryCallback = null;
55
+ workerLogCallback = null;
55
56
  constructor(workspaceDir, configOverrides) {
56
57
  this.config = {
57
58
  ...DEFAULT_WORKER_CONFIG,
@@ -69,6 +70,23 @@ export class WorkerManager {
69
70
  setGalleryCallback(callback) {
70
71
  this.galleryCallback = callback;
71
72
  }
73
+ /**
74
+ * Set callback for worker log streaming (stdout/stderr)
75
+ */
76
+ setLogCallback(callback) {
77
+ this.workerLogCallback = callback;
78
+ }
79
+ emitWorkerLog(workerId, chunk, stream) {
80
+ if (!this.workerLogCallback)
81
+ return;
82
+ const lines = chunk.split(/\r?\n/);
83
+ for (const line of lines) {
84
+ const trimmed = line.trim();
85
+ if (!trimmed)
86
+ continue;
87
+ this.workerLogCallback(workerId, trimmed.slice(0, 500), stream);
88
+ }
89
+ }
72
90
  /**
73
91
  * Get gallery workers with assets for UI
74
92
  */
@@ -174,10 +192,18 @@ This ensures nothing is lost even if your output gets truncated.`;
174
192
  GH_TOKEN: process.env.GH_TOKEN,
175
193
  GITHUB_TOKEN: process.env.GITHUB_TOKEN
176
194
  };
195
+ // Ensure worker uses the user's workspace as HOME so ~/Lia-Hub maps correctly
196
+ spawnEnv = {
197
+ ...spawnEnv,
198
+ HOME: workspace,
199
+ LIA_WORKSPACE: workspace,
200
+ USER: spawnEnv.USER || 'lia',
201
+ LOGNAME: spawnEnv.LOGNAME || 'lia'
202
+ };
177
203
  if (isRoot) {
178
- // When running as root, use runuser to switch to 'lia' user while preserving env
179
- // runuser -l creates a login shell but -m preserves the environment
180
- shellCmd = `runuser -u lia -- /bin/bash -l -c "${claudeCmd.replace(/"/g, '\\"')}"`;
204
+ // When running as root, use runuser to switch to 'lia' user while preserving env (HOME)
205
+ // runuser -m preserves env; -l ensures PATH resolution for nvm/claude
206
+ shellCmd = `runuser -u lia -m -- /bin/bash -l -c "${claudeCmd.replace(/"/g, '\\"')}"`;
181
207
  }
182
208
  else {
183
209
  shellCmd = claudeCmd;
@@ -198,10 +224,14 @@ This ensures nothing is lost even if your output gets truncated.`;
198
224
  let stdout = '';
199
225
  let stderr = '';
200
226
  child.stdout?.on('data', (data) => {
201
- stdout += data.toString();
227
+ const text = data.toString();
228
+ stdout += text;
229
+ this.emitWorkerLog(jobId, text, 'stdout');
202
230
  });
203
231
  child.stderr?.on('data', (data) => {
204
- stderr += data.toString();
232
+ const text = data.toString();
233
+ stderr += text;
234
+ this.emitWorkerLog(jobId, text, 'stderr');
205
235
  });
206
236
  // Set up timeout
207
237
  const timeoutHandle = setTimeout(() => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@siftd/connect-agent",
3
- "version": "0.2.37",
3
+ "version": "0.2.38",
4
4
  "description": "Master orchestrator agent - control Claude Code remotely via web",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",