askshepherd 0.1.32 → 0.1.36

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.
@@ -1,17 +1,18 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
- import { constants as fsConstants, mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs";
4
- import { access, mkdir, readFile, writeFile } from "node:fs/promises";
3
+ import { createHash } from "node:crypto";
4
+ import { constants as fsConstants, existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
5
+ import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
5
6
  import { createServer } from "node:http";
6
7
  import { homedir, platform } from "node:os";
7
- import { dirname, join } from "node:path";
8
+ import { basename, dirname, join } from "node:path";
8
9
  import readline from "node:readline";
9
10
  import { fileURLToPath } from "node:url";
10
11
 
11
12
  const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
12
13
  const PACKAGE_NAME = "askshepherd";
13
14
  const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
14
- const PACKAGE_VERSION = "0.1.32";
15
+ const PACKAGE_VERSION = "0.1.36";
15
16
  const MCP_SERVER_NAME = "shepherd";
16
17
  const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
17
18
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
@@ -22,7 +23,8 @@ const MAX_BATCH_SIZE = 50;
22
23
  const MAX_QUEUE_MESSAGES = 10_000;
23
24
  const DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT = 200;
24
25
  const INITIAL_MESSAGE_CHAT_ROWS = 20;
25
- const AGENT_MODALITY_ORDER = ["google", "slack", "granola", "messages"];
26
+ const ALL_MESSAGES_CHATS = "__shepherd_all_messages_chats__";
27
+ const AGENT_MODALITY_ORDER = ["google", "slack", "granola", "messages", "codingSessions"];
26
28
  const SHEPHERD_LOGO_PATH = join(PACKAGE_DIR, "assets", "shepherd_G_vector_136033.png");
27
29
  const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
28
30
  const GOOGLE_WORKSPACE_DELEGATION_ADMIN_URL = "https://admin.google.com/ac/owl/domainwidedelegation";
@@ -30,6 +32,15 @@ const MAC_FULL_DISK_ACCESS_URL = "x-apple.systempreferences:com.apple.settings.P
30
32
  const LEGACY_MAC_FULL_DISK_ACCESS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
31
33
  const MESSAGES_CHAT_DB_PATH = join(homedir(), "Library", "Messages", "chat.db");
32
34
  const MESSAGES_ATTACHMENTS_DIR = join(homedir(), "Library", "Messages", "Attachments");
35
+ const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions");
36
+ const CODEX_ARCHIVED_SESSIONS_DIR = join(homedir(), ".codex", "archived_sessions");
37
+ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
38
+ const CONTACTS_WAL_PATH = join(homedir(), "Library", "Application Support", "AddressBook", "AddressBook-v22.abcddb-wal");
39
+ const CONTACT_SYNC_DEBOUNCE_MS = 5_000;
40
+ const CONTACT_SYNC_FALLBACK_MS = 30 * 60_000;
41
+ const SHEPHERD_OWNED_MESSAGE_HANDLES = parseMessageHandleList(
42
+ process.env.SHEPHERD_OWNED_MESSAGE_HANDLES ?? process.env.SENDBLUE_NUMBER ?? "",
43
+ );
33
44
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
34
45
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
35
46
  "gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
@@ -120,6 +131,10 @@ async function dispatch() {
120
131
  await runMessagesChatsCommand();
121
132
  } else if (command === "messages-agent") {
122
133
  await runMessagesAgent();
134
+ } else if (command === "coding-sessions-agent") {
135
+ await runCodingSessionsAgent();
136
+ } else if (command === "coding-sessions-status") {
137
+ await runCodingSessionsStatus();
123
138
  } else {
124
139
  throw new Error(`Unknown command: ${command}`);
125
140
  }
@@ -143,12 +158,7 @@ async function runOnboarding() {
143
158
  const name = stringArg("name") ?? authenticatedName(workosLogin.authenticated) ?? await valueOrPrompt("name", "Full name");
144
159
  const organizationName = await valueOrPrompt("org", "Organization name");
145
160
 
146
- const sources = {
147
- google: !args["no-google"],
148
- slack: !args["no-slack"],
149
- granola: !args["no-granola"],
150
- messages: !args["no-messages"],
151
- };
161
+ const sources = selectedSources();
152
162
 
153
163
  const session = await postJson(`${apiUrl}/onboarding/raw/session`, {
154
164
  email,
@@ -237,6 +247,29 @@ async function runOnboarding() {
237
247
  }
238
248
  }
239
249
 
250
+ if (finalized.connected?.codingSessions?.agentToken) {
251
+ const configPath = await writeCodingSessionsConfig({
252
+ apiUrl,
253
+ userId: session.sessionId,
254
+ agentToken: finalized.connected.codingSessions.agentToken,
255
+ intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
256
+ });
257
+
258
+ if (!args["no-install-coding-sessions-agent"]) {
259
+ const install = await installCodingSessionsAgent(configPath, session.sessionId).catch((err) => ({
260
+ error: safeError(err),
261
+ }));
262
+ if ("error" in install) {
263
+ console.log(`\nLocal coding-session credentials saved: ${configPath}`);
264
+ console.log(`Coding-session background sync was not started: ${install.error}`);
265
+ } else {
266
+ console.log(`\nLocal coding-session sync started: ${install.label}`);
267
+ }
268
+ } else {
269
+ console.log(`\nLocal coding-session credentials saved: ${configPath}`);
270
+ }
271
+ }
272
+
240
273
  const connected = Object.keys(finalized.connected ?? {});
241
274
  console.log(`\nConnected sources: ${connected.length ? connected.join(", ") : "none"}`);
242
275
 
@@ -280,6 +313,10 @@ async function runAgentOnboarding() {
280
313
  || args["no-slack"]
281
314
  || args["no-granola"]
282
315
  || args["no-messages"]
316
+ || args["no-coding-sessions"]
317
+ || args["coding-sessions"]
318
+ || stringArg("sources")
319
+ || stringArg("add-sources")
283
320
  );
284
321
 
285
322
  if (!wantsStart) {
@@ -339,7 +376,7 @@ async function runAgentOnboarding() {
339
376
  currentAction,
340
377
  statePath,
341
378
  messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
342
- nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`,
379
+ nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`,
343
380
  needsUserAction: agentNeedsUserAction(sources, currentAction),
344
381
  }, null, 2));
345
382
  return;
@@ -358,7 +395,7 @@ async function runAgentOnboarding() {
358
395
  });
359
396
 
360
397
  console.log("\nAfter that modality is complete, run:");
361
- console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
398
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`);
362
399
  console.log(" Omit either optional flag if that source is not being connected.");
363
400
  }
364
401
 
@@ -869,7 +906,7 @@ async function continueAgentOnboarding() {
869
906
  if (granolaApiKey) body.granolaApiKey = granolaApiKey;
870
907
  if (messagesHandle) body.imessage = { handle: messagesHandle };
871
908
  if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
872
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
909
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>" or --messages-chat-ids all.`);
873
910
  }
874
911
 
875
912
  const finalized = await postJson(
@@ -880,12 +917,19 @@ async function continueAgentOnboarding() {
880
917
  state = await updateAgentStateFromOnboardingResponse(state, finalized);
881
918
 
882
919
  if (finalized.connected?.messages?.agentToken) {
920
+ const selectedChats = selectedChatIdsIncludeAll(selectedMessageChatIds)
921
+ ? [allMessagesChatsSelection()]
922
+ : await loadSelectedMessageChatsForConfig(selectedMessageChatIds).catch((err) => {
923
+ console.warn(`Could not load selected Messages chat metadata for contact hydration: ${safeError(err)}`);
924
+ return [];
925
+ });
883
926
  const configPath = await writeMessagesConfig({
884
927
  apiUrl: state.apiUrl,
885
928
  userId: state.sessionId,
886
929
  agentToken: finalized.connected.messages.agentToken,
887
930
  backfillDays: parseBackfillDays(args["messages-backfill-days"], null),
888
931
  allowedChatIds: selectedMessageChatIds,
932
+ selectedChats,
889
933
  });
890
934
 
891
935
  if (!args["no-install-messages-agent"]) {
@@ -902,6 +946,29 @@ async function continueAgentOnboarding() {
902
946
  }
903
947
  }
904
948
 
949
+ if (finalized.connected?.codingSessions?.agentToken) {
950
+ const configPath = await writeCodingSessionsConfig({
951
+ apiUrl: state.apiUrl,
952
+ userId: state.sessionId,
953
+ agentToken: finalized.connected.codingSessions.agentToken,
954
+ intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
955
+ });
956
+
957
+ if (!args["no-install-coding-sessions-agent"]) {
958
+ const install = await installCodingSessionsAgent(configPath, state.sessionId).catch((err) => ({
959
+ error: safeError(err),
960
+ }));
961
+ if ("error" in install) {
962
+ console.log(`Coding-session credentials saved: ${configPath}`);
963
+ console.log(`Coding-session background sync was not started: ${install.error}`);
964
+ } else {
965
+ console.log(`Coding-session background sync started: ${install.label}`);
966
+ }
967
+ } else {
968
+ console.log(`Coding-session credentials saved: ${configPath}`);
969
+ }
970
+ }
971
+
905
972
  const errors = finalized.errors && Object.keys(finalized.errors).length ? finalized.errors : null;
906
973
  const currentAction = errors
907
974
  ? await openNextAgentModality({
@@ -915,11 +982,12 @@ async function continueAgentOnboarding() {
915
982
  console.log(JSON.stringify({
916
983
  status: errors ? "waiting" : "completed",
917
984
  connected: Object.keys(finalized.connected ?? {}),
985
+ alreadyConnected: Object.keys(finalized.alreadyConnected ?? {}),
918
986
  processingEnabled: finalized.processingEnabled === true,
919
987
  processing: finalized.processing,
920
988
  errors: errors ? safeErrorRecord(errors) : undefined,
921
989
  currentAction,
922
- nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"` : undefined,
990
+ nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"` : undefined,
923
991
  mcpInstall: errors ? undefined : {
924
992
  prompt: "Ask where to install Shepherd MCP for this customer: Codex, Claude Code, Cursor, any subset, or none.",
925
993
  targets: MCP_INSTALL_TARGETS,
@@ -940,7 +1008,7 @@ async function continueAgentOnboarding() {
940
1008
  });
941
1009
 
942
1010
  console.log("\nAfter that modality is complete, rerun:");
943
- console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"`);
1011
+ console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`);
944
1012
  console.log(" Omit either optional flag if that source is not being connected.");
945
1013
  return;
946
1014
  }
@@ -982,8 +1050,10 @@ async function runMessagesChatsCommand() {
982
1050
 
983
1051
  if (!args.text && !args.list) {
984
1052
  const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
985
- const selectedIds = selected.map((chat) => chat.chatId).join(",");
986
- console.log(`\nSelected ${selected.length} Messages chat(s).`);
1053
+ const selectedIds = selectedIncludesAllChats(selected) ? "all" : selected.map((chat) => chat.chatId).join(",");
1054
+ console.log(selectedIncludesAllChats(selected)
1055
+ ? "\nSelected all current and future Messages chats."
1056
+ : `\nSelected ${selected.length} Messages chat(s).`);
987
1057
  console.log(`messages-chat-ids=${selectedIds}`);
988
1058
  console.log("\nContinue with:");
989
1059
  console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
@@ -997,6 +1067,7 @@ async function runMessagesChatsCommand() {
997
1067
  }
998
1068
  console.log("\nPass selected IDs to:");
999
1069
  console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
1070
+ console.log(` Or explicitly sync all current and future chats with: --messages-chat-ids all`);
1000
1071
  }
1001
1072
 
1002
1073
  async function runMessagesAgent() {
@@ -1007,37 +1078,157 @@ async function runMessagesAgent() {
1007
1078
  const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
1008
1079
  const userId = requiredConfigString(config.userId, "userId");
1009
1080
  const agentToken = requiredConfigString(config.agentToken, "agentToken");
1081
+ mergeShepherdOwnedMessageHandles(config.excludedMessageHandles);
1010
1082
  const backfillDays = parseBackfillDays(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays, null);
1011
1083
  const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
1012
- if (allowedChatIds.length === 0) {
1013
- throw new Error("Messages config must include selected chat IDs. Re-run onboarding and select one or more recent Messages chats.");
1084
+ const allChats = config.allChats === true || selectedChatIdsIncludeAll(allowedChatIds);
1085
+ if (!allChats && allowedChatIds.length === 0) {
1086
+ throw new Error("Messages config must include selected chat IDs or allChats=true. Re-run onboarding and select chats, or pass --messages-chat-ids all.");
1014
1087
  }
1015
1088
 
1016
1089
  const kit = await import("@photon-ai/imessage-kit");
1017
1090
  const sdk = new kit.IMessageSDK({ debug: args.debug === true });
1018
1091
  const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
1019
- const contactLookup = buildContactLookup();
1092
+ const contactLookup = createMutableContactLookup(buildContactLookup());
1020
1093
  const serializer = createMessageSerializer(kit, contactLookup);
1094
+ const contactSync = startMessagesContactSync(sender, contactLookup, {
1095
+ syncAllContacts: allChats,
1096
+ seedHandles: allChats ? [] : selectedChatContactSeedHandles(config.selectedChats, allowedChatIds),
1097
+ });
1021
1098
 
1022
1099
  console.log("Shepherd Messages raw sync starting");
1023
- console.log(`Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
1100
+ console.log(allChats
1101
+ ? "Messages chat filter: all current and future chats"
1102
+ : `Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
1024
1103
 
1025
1104
  try {
1105
+ await contactSync.syncNow({ forceAll: true, reason: "startup" }).catch((err) => {
1106
+ console.error("Initial Messages contact sync failed:", safeError(err));
1107
+ });
1026
1108
  await loadGroupChatNames(sdk, serializer);
1027
1109
  loadSelectedChatNames(config.selectedChats, serializer);
1028
1110
 
1029
1111
  if (backfillDays !== 0) {
1030
- await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
1112
+ await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync);
1113
+ await contactSync.syncNow({ forceAll: true, reason: "post-backfill" }).catch((err) => {
1114
+ console.error("Post-backfill Messages contact sync failed:", safeError(err));
1115
+ });
1031
1116
  }
1032
1117
 
1033
- await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
1034
- await watchMessages(sdk, sender, serializer, userId, allowedChatIds);
1118
+ await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync);
1119
+ await contactSync.syncNow({ forceAll: true, reason: "post-gap-fill" }).catch((err) => {
1120
+ console.error("Post-gap-fill Messages contact sync failed:", safeError(err));
1121
+ });
1122
+ await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, { contactSync });
1035
1123
  } catch (err) {
1124
+ contactSync.stop();
1036
1125
  await sdk.close?.().catch(() => undefined);
1037
1126
  throw err;
1038
1127
  }
1039
1128
  }
1040
1129
 
1130
+ async function runCodingSessionsAgent() {
1131
+ const configPath = stringArg("config");
1132
+ if (!configPath) throw new Error("coding-sessions-agent requires --config <path>");
1133
+
1134
+ const config = JSON.parse(await readFile(configPath, "utf8"));
1135
+ const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
1136
+ const userId = requiredConfigString(config.userId, "userId");
1137
+ const agentToken = requiredConfigString(config.agentToken, "agentToken");
1138
+ const sender = new CodingSessionsBatchSender(apiUrl, agentToken, userId);
1139
+ const statePath = codingSessionsStateFile(userId);
1140
+ const statusPath = codingSessionsStatusFile(userId);
1141
+ const intervalSeconds = Math.max(15, Math.floor(Number(args["interval-seconds"] ?? config.intervalSeconds ?? 60)));
1142
+
1143
+ console.log("Shepherd Coding Sessions sync starting");
1144
+ while (true) {
1145
+ const startedAt = new Date().toISOString();
1146
+ const previous = readCodingSessionsState(statePath);
1147
+ const scan = await scanCodingSessions(config, previous);
1148
+ const changed = scan.sessions.filter((session) => previous.hashes[session.sourcePathHash] !== session.contentHash);
1149
+ const sendResult = changed.length > 0 ? await sender.send(changed) : { stored: 0, updated: 0, skipped: 0 };
1150
+ const nextState = {
1151
+ hashes: { ...previous.hashes },
1152
+ updatedAt: new Date().toISOString(),
1153
+ };
1154
+ for (const session of changed) {
1155
+ nextState.hashes[session.sourcePathHash] = session.contentHash;
1156
+ }
1157
+ writeFileSync(statePath, JSON.stringify(nextState, null, 2), { mode: 0o600 });
1158
+ const status = {
1159
+ ok: true,
1160
+ startedAt,
1161
+ finishedAt: new Date().toISOString(),
1162
+ scanned: scan.sessions.length,
1163
+ changed: changed.length,
1164
+ sent: sendResult,
1165
+ probes: scan.probes,
1166
+ errors: scan.errors,
1167
+ };
1168
+ writeFileSync(statusPath, JSON.stringify(status, null, 2), { mode: 0o600 });
1169
+ console.log(`Coding session scan: ${scan.sessions.length} scanned, ${changed.length} changed, ${sendResult.stored ?? 0} stored, ${sendResult.updated ?? 0} updated, ${sendResult.skipped ?? 0} skipped`);
1170
+
1171
+ if (args.once) return;
1172
+ await sleep(intervalSeconds * 1000);
1173
+ }
1174
+ }
1175
+
1176
+ async function runCodingSessionsStatus() {
1177
+ const configPath = stringArg("config") ?? await latestCodingSessionsConfigPath();
1178
+ const config = configPath ? JSON.parse(await readFile(configPath, "utf8")) : null;
1179
+ const userId = config?.userId ?? null;
1180
+ const safeId = userId ? safeFileId(userId) : null;
1181
+ const label = safeId ? `ai.shepherd.coding-sessions.${safeId}` : null;
1182
+ const probes = await probeCodingSessionPaths(config ?? {});
1183
+ const lastSync = userId ? readJsonOptional(codingSessionsStatusFile(userId)) : null;
1184
+ const queue = userId ? readJsonOptional(join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-queue.json`)) : null;
1185
+ const launch = label && platform() === "darwin"
1186
+ ? {
1187
+ label,
1188
+ state: readLaunchctlPrint(`gui/${process.getuid?.() ?? 501}/${label}`),
1189
+ }
1190
+ : null;
1191
+ const production = await productionOnboardingStatusForCodingSessions(config).catch((err) => ({ error: safeError(err) }));
1192
+ const status = {
1193
+ configPath: configPath ?? null,
1194
+ userId,
1195
+ localFolders: probes,
1196
+ launch,
1197
+ lastSync,
1198
+ queueDepth: Array.isArray(queue) ? queue.length : 0,
1199
+ production,
1200
+ };
1201
+
1202
+ if (args.json) {
1203
+ console.log(JSON.stringify(status, null, 2));
1204
+ return;
1205
+ }
1206
+
1207
+ console.log("\nShepherd coding-session sync status\n");
1208
+ console.log(`Config: ${configPath ?? "not found"}`);
1209
+ if (userId) console.log(`User: ${userId}`);
1210
+ for (const probe of probes) {
1211
+ console.log(`- ${probe.provider}: ${probe.path} ${probe.readable ? "readable" : `not readable (${probe.reason})`}`);
1212
+ }
1213
+ if (launch) {
1214
+ const running = /state = running|job state = running/i.test(launch.state);
1215
+ console.log(`LaunchAgent: ${launch.label} ${running ? "running" : "not running or unknown"}`);
1216
+ } else {
1217
+ console.log("LaunchAgent: not installed or unavailable");
1218
+ }
1219
+ if (lastSync) {
1220
+ console.log(`Last sync: ${lastSync.finishedAt ?? "unknown"} (${lastSync.scanned ?? 0} scanned, ${lastSync.changed ?? 0} changed)`);
1221
+ } else {
1222
+ console.log("Last sync: none recorded");
1223
+ }
1224
+ console.log(`Queued unsent sessions: ${Array.isArray(queue) ? queue.length : 0}`);
1225
+ if (production?.providers?.codingSessions) {
1226
+ console.log(`Production provider: ${production.providers.codingSessions.connected ? "connected" : "not connected"}`);
1227
+ } else if (production?.error) {
1228
+ console.log(`Production provider: unavailable (${production.error})`);
1229
+ }
1230
+ }
1231
+
1041
1232
  function parseArgs(argv) {
1042
1233
  const parsed = {};
1043
1234
  for (let i = 0; i < argv.length; i++) {
@@ -1070,8 +1261,10 @@ Usage:
1070
1261
  npx -y ${PACKAGE_NAME}@latest agent
1071
1262
  npx -y ${PACKAGE_NAME}@latest agent --login
1072
1263
  npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
1073
- npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
1264
+ npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids|all>
1265
+ npx -y ${PACKAGE_NAME}@latest agent --add-sources coding-sessions --name <name> --org <organization>
1074
1266
  npx -y ${PACKAGE_NAME}@latest agent --status
1267
+ npx -y ${PACKAGE_NAME}@latest coding-sessions-status
1075
1268
  npx -y ${PACKAGE_NAME}@latest messages-chats
1076
1269
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
1077
1270
 
@@ -1081,6 +1274,40 @@ The bare agent command is intended for coding-agent shells. For direct terminal
1081
1274
  return;
1082
1275
  }
1083
1276
 
1277
+ if (which === "coding-sessions-agent") {
1278
+ console.log(`Shepherd coding-session sync agent
1279
+
1280
+ Usage:
1281
+ shepherd-onboard coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json
1282
+
1283
+ Options:
1284
+ --config <path> Coding-session agent config created by onboarding.
1285
+ --once Run one scan and exit.
1286
+ --interval-seconds <n> Poll interval for live sync. Defaults to config or 60.
1287
+ --debug Print extra collector details.
1288
+ --help Show this help.
1289
+ `);
1290
+ return;
1291
+ }
1292
+
1293
+ if (which === "coding-sessions-status") {
1294
+ console.log(`Shepherd coding-session sync status
1295
+
1296
+ Usage:
1297
+ npx -y ${PACKAGE_NAME}@latest coding-sessions-status
1298
+ npx -y ${PACKAGE_NAME}@latest coding-sessions-status --json
1299
+
1300
+ Shows whether local Codex and Claude Code session folders are readable, whether
1301
+ the background LaunchAgent is installed/running, and the last local sync result.
1302
+
1303
+ Options:
1304
+ --config <path> Config path. Defaults to the latest file in ~/.shepherd/coding-sessions.
1305
+ --json Print machine-readable status.
1306
+ --help Show this help.
1307
+ `);
1308
+ return;
1309
+ }
1310
+
1084
1311
  if (which === "messages-agent") {
1085
1312
  console.log(`Shepherd Messages raw sync agent
1086
1313
 
@@ -1110,6 +1337,7 @@ macOS permission:
1110
1337
 
1111
1338
  Options:
1112
1339
  --limit <n> Number of recent chats to load for search. Defaults to ${DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT}.
1340
+ --messages-chat-ids all Supported on agent --continue to watch every current and future Messages chat.
1113
1341
  --text Print a terminal list instead of opening the selector page.
1114
1342
  --no-open Print the local selector URL instead of opening it.
1115
1343
  --no-permission-prompt Print macOS permission instructions without waiting for confirmation.
@@ -1195,6 +1423,7 @@ Usage:
1195
1423
  npx -y ${PACKAGE_NAME}@latest mcp-login
1196
1424
  npx -y ${PACKAGE_NAME}@latest mcp-install
1197
1425
  npx -y ${PACKAGE_NAME}@latest messages-chats
1426
+ npx -y ${PACKAGE_NAME}@latest coding-sessions-status
1198
1427
  npx -y ${PACKAGE_NAME}@latest granola-api-keys
1199
1428
 
1200
1429
  Options:
@@ -1203,7 +1432,7 @@ Options:
1203
1432
  --org <name> Organization name.
1204
1433
  --granola-api-key <key> Granola API key.
1205
1434
  --messages-handle <value> Messages phone number or Apple ID email.
1206
- --messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats.
1435
+ --messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats, or all to watch every current and future chat.
1207
1436
  --messages-backfill-days <days>
1208
1437
  Local Messages backfill window. Defaults to all selected chat history.
1209
1438
  --no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
@@ -1211,8 +1440,13 @@ Options:
1211
1440
  --no-granola Skip Granola.
1212
1441
  --no-open-granola Do not open the Granola API key screen.
1213
1442
  --no-messages Skip local Messages.
1443
+ --coding-sessions Opt in to local Codex/Claude Code session summary sync.
1444
+ --sources <list> Exact sources to connect: google,slack,granola,messages,coding-sessions,all.
1445
+ --add-sources <list> Same as --sources, named for second-time onboarding.
1214
1446
  --no-install-messages-agent
1215
1447
  Save Messages credentials without starting launchd.
1448
+ --no-install-coding-sessions-agent
1449
+ Save coding-session credentials without starting launchd.
1216
1450
  --no-open Print auth URLs instead of opening the browser.
1217
1451
  --no-permission-prompt Print macOS permission instructions without waiting for confirmation.
1218
1452
  --api <url> Advanced: Shepherd API URL.
@@ -1255,9 +1489,11 @@ function printAgentContract() {
1255
1489
  "Tell the user Shepherd verifies existing-org joins from the authenticated WorkOS account and company email domain. The typed org name is not trusted by itself.",
1256
1490
  "If Google Workspace is selected, guide the customer's Google Workspace super admin to authorize Shepherd's Client ID and scopes in Google Admin Console.",
1257
1491
  "Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
1492
+ "Ask Coding Sessions as a selectable choice: Skip Coding Sessions, or Sync Codex/Claude Code session summaries.",
1258
1493
  "If the user chooses Provide handle, ask for the phone number or Apple ID email.",
1259
1494
  "If Messages is selected, ask the user to grant or confirm macOS Full Disk Access for the app running onboarding and the Node.js binary used by background sync. Shepherd checks this and keeps prompting until access works in interactive onboarding.",
1260
- "If Messages is selected, run the recent-chat command. It opens a browser selector with recent chats and search. Never sync all Messages chats by default.",
1495
+ "If Messages is selected, run the recent-chat command. It opens a browser selector with recent chats and search. Never sync all Messages chats by default; use all only when the user explicitly asks for every current and future chat.",
1496
+ "If Coding Sessions is selected, continue onboarding installs a local LaunchAgent that reads Codex and Claude Code session logs, summarizes them, and syncs summaries. It usually does not require Full Disk Access unless macOS denies access to ~/.codex or ~/.claude.",
1261
1497
  "After raw onboarding completes, ask whether they want Shepherd MCP installed for their signed-in customer account into Codex, Claude Code, Cursor, any subset, or none.",
1262
1498
  ],
1263
1499
  selectionQuestions: [
@@ -1269,7 +1505,7 @@ function printAgentContract() {
1269
1505
  {
1270
1506
  label: "Sources",
1271
1507
  prompt: "Which sources should Shepherd connect for raw sync?",
1272
- options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
1508
+ options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages", "Coding Sessions (Codex/Claude Code summaries)"],
1273
1509
  multiSelect: true,
1274
1510
  },
1275
1511
  {
@@ -1290,6 +1526,7 @@ function printAgentContract() {
1290
1526
  "Messages phone number or Apple ID email, if they want local Messages connected",
1291
1527
  "Full Disk Access confirmation, if they want local Messages connected on macOS",
1292
1528
  "Selected local Messages chats from the browser selector, if they want local Messages connected",
1529
+ "Coding-session sync consent, if they want Codex and Claude Code session summaries connected",
1293
1530
  "MCP install targets after onboarding completes: Codex, Claude Code, Cursor, any subset, or none",
1294
1531
  ],
1295
1532
  afterStartCommand: [
@@ -1308,22 +1545,29 @@ function printAgentContract() {
1308
1545
  ],
1309
1546
  loginCommand: `${command} agent --login`,
1310
1547
  startCommand: `${command} agent --name "<full_name>" --org "<organization>"`,
1548
+ addSourcesCommand: `${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"`,
1311
1549
  continueCommand: `${command} agent --continue`,
1312
1550
  mcpLoginCommand: `${command} mcp-login`,
1313
1551
  optionalContinueArgs: [
1314
1552
  "--messages-handle \"<phone_or_apple_id>\" if local Messages is being connected",
1315
- "--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected",
1553
+ "--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected, or --messages-chat-ids all when the user explicitly wants every current and future Messages chat watched",
1316
1554
  "--granola-api-key \"<granola_key>\" if Granola is being connected",
1317
1555
  ],
1318
1556
  statusCommand: `${command} agent --status`,
1319
1557
  messagesChatsCommand: `${command} messages-chats`,
1320
1558
  messagesPermissions: {
1321
- macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Background sync install also checks that launchd can start the Messages agent. Contacts permission may also appear when resolving local contact names.",
1559
+ macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Background sync install also checks that launchd can start the Messages agent. Contacts permission may also appear when resolving local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.",
1322
1560
  nodeBinary: process.execPath,
1323
1561
  },
1562
+ codingSessions: {
1563
+ sourceFlag: "--sources coding-sessions or --add-sources coding-sessions",
1564
+ statusCommand: `${command} coding-sessions-status`,
1565
+ localAgentCommand: `${command} coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json`,
1566
+ privacy: "Shepherd uploads bounded summaries and evidence metadata, not full raw Codex or Claude Code transcripts. Secrets, auth headers, private keys, credential URLs, high-entropy tokens, and home paths are redacted.",
1567
+ },
1324
1568
  googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
1325
1569
  orgSecurity: "Existing organizations are only reused when Shepherd can verify the authenticated user belongs there, for example by an existing Shepherd account/membership or matching non-personal company email domain. Similar spelling helps match verified orgs, but cannot attach an unverified user to someone else's org.",
1326
- expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd production environment. Finalize asks production brain services to schedule downstream ingestion batches, wiki ingestion, memory artifacts, and document summaries. Local Messages starts via a macOS LaunchAgent when run on macOS.",
1570
+ expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd production environment. Finalize asks production brain services to schedule downstream ingestion batches, wiki ingestion, memory artifacts, document summaries, and coding-session wiki artifacts. Local Messages and Coding Sessions start via macOS LaunchAgents when run on macOS.",
1327
1571
  granolaApiKeyCommand: `${command} granola-api-keys`,
1328
1572
  granolaApiKeyPath: "Granola desktop app -> Settings -> Connectors -> API keys",
1329
1573
  };
@@ -1343,7 +1587,7 @@ Ask with short interactive prompts, not as one pasted checklist. Do not paste th
1343
1587
 
1344
1588
  Start with selection questions to determine intent:
1345
1589
  1. Organization: Join existing org, or Create new org.
1346
- 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages. Allow multi-select if your interface supports it.
1590
+ 2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages, Coding Sessions (Codex/Claude Code summaries). Allow multi-select if your interface supports it.
1347
1591
  3. Messages, if selected: Skip Messages, or Provide handle.
1348
1592
  4. MCP install after onboarding completes: Codex, Claude Code, Cursor, any subset, or none.
1349
1593
 
@@ -1369,9 +1613,9 @@ If Messages is selected, run:
1369
1613
 
1370
1614
  Before or during this step, ask the user to grant or confirm macOS Full Disk Access for local Messages sync. The command validates access to the local Messages database, opens System Settings -> Privacy & Security -> Full Disk Access if access is missing, and keeps checking until access works in interactive onboarding. The user should enable the app running onboarding, such as Terminal, iTerm, Claude Code, or Codex, and Node.js for background sync:
1371
1615
  ${payload.messagesPermissions.nodeBinary}
1372
- Contacts permission may also appear when Shepherd resolves local contact names.
1616
+ Contacts permission may also appear when Shepherd resolves local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.
1373
1617
 
1374
- This opens a minimal local webpage with recent local Messages chats and search. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. When the command returns, keep the printed comma-separated chat IDs.
1618
+ This opens a minimal local webpage with recent local Messages chats and search. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. If the user explicitly wants everything, use the "Sync all current and future chats" checkbox or pass --messages-chat-ids all. All-chats mode backfills current chats and keeps watching chats that appear later. When the command returns, keep the printed chat IDs or the literal value all.
1375
1619
 
1376
1620
  Then run:
1377
1621
  ${payload.startCommand}
@@ -1382,6 +1626,9 @@ Add skip flags for sources the user did not select:
1382
1626
  - --no-granola
1383
1627
  - --no-messages
1384
1628
 
1629
+ Or pass an exact source list, especially for adding sources later:
1630
+ ${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"
1631
+
1385
1632
  That command creates/reuses the customer user and org, saves local state, and opens at most one source setup surface. It works one modality at a time after account setup: Google Workspace, then Slack, then Granola. If Messages details are still missing, it prints the Messages selector command instead of opening another auth surface. Do not manually open later source setup surfaces until the command tells you that source is the current modality.
1386
1633
 
1387
1634
  If Google Workspace is the current modality, the setup command opens the Admin Console domain-wide delegation page. Show this setup to the user and have their Google Workspace super admin authorize it:
@@ -1409,13 +1656,18 @@ If Granola is the current modality and did not come forward, run:
1409
1656
  That command opens Granola and tries to navigate to Settings -> Connectors -> API keys. If your tool cannot click inside Granola, leave Granola open and ask the user to go to that screen.
1410
1657
 
1411
1658
  After the current modality is complete, run:
1412
- ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids>" --granola-api-key "<granola_key>"
1659
+ ${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"
1413
1660
 
1414
1661
  Omit either optional flag if that source is not being connected.
1415
1662
 
1663
+ If Coding Sessions was selected, the continue step installs local Codex/Claude Code session summary sync. It probes ~/.codex and ~/.claude, redacts sensitive strings, and uploads bounded summaries and work metadata rather than full transcripts. It usually does not need Full Disk Access unless macOS denies access to those folders.
1664
+
1416
1665
  Check progress with:
1417
1666
  ${payload.statusCommand}
1418
1667
 
1668
+ Check local coding-session sync with:
1669
+ ${payload.codingSessions.statusCommand}
1670
+
1419
1671
  After onboarding completes, ask whether to install Shepherd MCP for the signed-in customer into Codex, Claude Code, Cursor, any subset, or none.
1420
1672
  If they choose any targets, run:
1421
1673
  ${payload.mcpLoginCommand} --install "<codex,claude,cursor>"
@@ -1432,12 +1684,74 @@ function hasIdentityArgs() {
1432
1684
  }
1433
1685
 
1434
1686
  function selectedSources() {
1435
- return {
1436
- google: !args["no-google"],
1437
- slack: !args["no-slack"],
1438
- granola: !args["no-granola"],
1439
- messages: !args["no-messages"],
1687
+ const explicitSources = stringArg("sources") ?? stringArg("add-sources");
1688
+ const selected = explicitSources
1689
+ ? sourceSelectionFromList(explicitSources)
1690
+ : {
1691
+ google: !args["no-google"],
1692
+ slack: !args["no-slack"],
1693
+ granola: !args["no-granola"],
1694
+ messages: !args["no-messages"],
1695
+ codingSessions: args["coding-sessions"] === true || args["coding-sessions"] === "true",
1696
+ };
1697
+
1698
+ if (args["no-google"]) selected.google = false;
1699
+ if (args["no-slack"]) selected.slack = false;
1700
+ if (args["no-granola"]) selected.granola = false;
1701
+ if (args["no-messages"]) selected.messages = false;
1702
+ if (args["no-coding-sessions"]) selected.codingSessions = false;
1703
+ if (args["coding-sessions"]) selected.codingSessions = true;
1704
+
1705
+ return selected;
1706
+ }
1707
+
1708
+ function sourceSelectionFromList(value) {
1709
+ const selected = {
1710
+ google: false,
1711
+ slack: false,
1712
+ granola: false,
1713
+ messages: false,
1714
+ codingSessions: false,
1440
1715
  };
1716
+ const aliases = new Map([
1717
+ ["google", "google"],
1718
+ ["gmail", "google"],
1719
+ ["drive", "google"],
1720
+ ["docs", "google"],
1721
+ ["gdocs", "google"],
1722
+ ["calendar", "google"],
1723
+ ["slack", "slack"],
1724
+ ["granola", "granola"],
1725
+ ["messages", "messages"],
1726
+ ["imessage", "messages"],
1727
+ ["imessages", "messages"],
1728
+ ["coding-sessions", "codingSessions"],
1729
+ ["coding_sessions", "codingSessions"],
1730
+ ["codingsessions", "codingSessions"],
1731
+ ["sessions", "codingSessions"],
1732
+ ["codex", "codingSessions"],
1733
+ ["claude", "codingSessions"],
1734
+ ["claude-code", "codingSessions"],
1735
+ ]);
1736
+ const raw = String(value ?? "").trim().toLowerCase();
1737
+ if (!raw) throw new Error("--sources requires at least one source.");
1738
+ const parts = raw.split(/[,\s]+/).filter(Boolean);
1739
+ for (const part of parts) {
1740
+ if (part === "all") {
1741
+ selected.google = true;
1742
+ selected.slack = true;
1743
+ selected.granola = true;
1744
+ selected.messages = true;
1745
+ selected.codingSessions = true;
1746
+ continue;
1747
+ }
1748
+ const source = aliases.get(part);
1749
+ if (!source) {
1750
+ throw new Error(`Unknown source "${part}". Use google, slack, granola, messages, coding-sessions, or all.`);
1751
+ }
1752
+ selected[source] = true;
1753
+ }
1754
+ return selected;
1441
1755
  }
1442
1756
 
1443
1757
  async function writeAgentState(state) {
@@ -1618,6 +1932,16 @@ async function openNextAgentModality({ sources, authUrls = {}, noOpen = false, p
1618
1932
  message: "Run the local Messages chat selector and keep the printed chat IDs.",
1619
1933
  };
1620
1934
  }
1935
+
1936
+ if (source === "codingSessions") {
1937
+ return {
1938
+ source,
1939
+ label: "Coding Sessions",
1940
+ opened: false,
1941
+ command: `${agentCommand()} agent --continue`,
1942
+ message: "Continue onboarding to install local Codex and Claude Code session sync.",
1943
+ };
1944
+ }
1621
1945
  }
1622
1946
 
1623
1947
  return null;
@@ -1665,6 +1989,12 @@ function printAgentCurrentAction(action, opts = {}) {
1665
1989
  console.log(`Run: ${action.command}`);
1666
1990
  console.log("Have the user select specific local Messages chats; do not select all by default.");
1667
1991
  console.log("Ask the user to grant or confirm macOS Full Disk Access for the app running onboarding and Node.js before installing background Messages sync.");
1992
+ return;
1993
+ }
1994
+
1995
+ if (action.source === "codingSessions") {
1996
+ console.log(`Run: ${action.command}`);
1997
+ console.log("This installs a local LaunchAgent that summarizes Codex and Claude Code session logs and syncs those summaries to Shepherd.");
1668
1998
  }
1669
1999
  }
1670
2000
 
@@ -1674,6 +2004,7 @@ function agentNeedsUserAction(sources, action) {
1674
2004
  if (action.source === "slack") return ["Complete Slack browser authorization."];
1675
2005
  if (action.source === "granola") return ["Create/copy a Granola API key from the Granola Mac app."];
1676
2006
  if (action.source === "messages") return ["Grant or confirm macOS Full Disk Access for the onboarding app and Node.js, run messages-chats, have the user select local Messages contacts/groups in the browser, then pass the printed chat IDs with the Messages handle."];
2007
+ if (action.source === "codingSessions") return ["Run the continue command to install local Codex and Claude Code session summary sync."];
1677
2008
  return [];
1678
2009
  }
1679
2010
 
@@ -1828,7 +2159,7 @@ async function explainMessagesBackgroundPermissions(opts = {}) {
1828
2159
  console.log("\nMessages background sync permissions");
1829
2160
  console.log("Local Messages raw sync runs as a macOS LaunchAgent using npx/Node.js. For continuous sync, macOS Full Disk Access must include the background Node.js binary, not just the current terminal.");
1830
2161
  printMessagesPermissionTargets();
1831
- console.log("Contacts permission may also appear when Shepherd resolves local contact names for selected chats.");
2162
+ console.log("Contacts permission may also appear when Shepherd resolves local contact names. The background agent keeps contact names hydrated for observed Messages conversations.");
1832
2163
  await openFullDiskAccessSettings(opts);
1833
2164
 
1834
2165
  if (opts.waitForUser && process.stdin.isTTY && !args["no-permission-prompt"]) {
@@ -1940,8 +2271,9 @@ async function writeMessagesConfig(input) {
1940
2271
  await mkdir(dir, { recursive: true });
1941
2272
  const path = join(dir, `${input.userId}.json`);
1942
2273
  const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
1943
- if (allowedChatIds.length === 0) {
1944
- throw new Error("Select at least one Messages chat before installing local Messages sync.");
2274
+ const allChats = selectedChatIdsIncludeAll(allowedChatIds);
2275
+ if (!allChats && allowedChatIds.length === 0) {
2276
+ throw new Error("Select at least one Messages chat or pass --messages-chat-ids all before installing local Messages sync.");
1945
2277
  }
1946
2278
  await writeFile(
1947
2279
  path,
@@ -1950,8 +2282,10 @@ async function writeMessagesConfig(input) {
1950
2282
  userId: input.userId,
1951
2283
  agentToken: input.agentToken,
1952
2284
  backfillDays: input.backfillDays,
1953
- allowedChatIds,
2285
+ allChats,
2286
+ allowedChatIds: allChats ? [] : allowedChatIds,
1954
2287
  selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
2288
+ excludedMessageHandles: SHEPHERD_OWNED_MESSAGE_HANDLES,
1955
2289
  createdAt: new Date().toISOString(),
1956
2290
  }, null, 2),
1957
2291
  { mode: 0o600 },
@@ -2036,6 +2370,110 @@ async function installMessagesAgent(configPath, userId) {
2036
2370
  return { label, plistPath, stdoutPath, stderrPath };
2037
2371
  }
2038
2372
 
2373
+ async function writeCodingSessionsConfig(input) {
2374
+ const dir = join(homedir(), ".shepherd", "coding-sessions");
2375
+ await mkdir(dir, { recursive: true });
2376
+ const path = join(dir, `${input.userId}.json`);
2377
+ await writeFile(
2378
+ path,
2379
+ JSON.stringify({
2380
+ apiUrl: input.apiUrl,
2381
+ userId: input.userId,
2382
+ agentToken: input.agentToken,
2383
+ intervalSeconds: Math.max(15, Math.floor(Number(input.intervalSeconds) || 60)),
2384
+ codexDirs: [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR],
2385
+ claudeProjectsDir: CLAUDE_PROJECTS_DIR,
2386
+ maxFilesPerProvider: 300,
2387
+ createdAt: new Date().toISOString(),
2388
+ }, null, 2),
2389
+ { mode: 0o600 },
2390
+ );
2391
+ return path;
2392
+ }
2393
+
2394
+ async function installCodingSessionsAgent(configPath, userId) {
2395
+ if (platform() !== "darwin") {
2396
+ throw new Error("automatic local coding-session sync is only supported on macOS");
2397
+ }
2398
+
2399
+ const safeId = safeFileId(userId);
2400
+ const label = `ai.shepherd.coding-sessions.${safeId}`;
2401
+ const rawDir = join(homedir(), ".shepherd", "coding-sessions");
2402
+ const agentsDir = join(homedir(), "Library", "LaunchAgents");
2403
+ await mkdir(rawDir, { recursive: true });
2404
+ await mkdir(agentsDir, { recursive: true });
2405
+
2406
+ const plistPath = join(agentsDir, `${label}.plist`);
2407
+ const stdoutPath = join(rawDir, `${safeId}.out.log`);
2408
+ const stderrPath = join(rawDir, `${safeId}.err.log`);
2409
+ const launchPath = launchAgentPath();
2410
+
2411
+ const plist = `<?xml version="1.0" encoding="UTF-8"?>
2412
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
2413
+ <plist version="1.0">
2414
+ <dict>
2415
+ <key>Label</key>
2416
+ <string>${xmlEscape(label)}</string>
2417
+ <key>ProgramArguments</key>
2418
+ <array>
2419
+ <string>/usr/bin/env</string>
2420
+ <string>npx</string>
2421
+ <string>-y</string>
2422
+ <string>${PACKAGE_SPEC}</string>
2423
+ <string>coding-sessions-agent</string>
2424
+ <string>--config</string>
2425
+ <string>${xmlEscape(configPath)}</string>
2426
+ </array>
2427
+ <key>KeepAlive</key>
2428
+ <true/>
2429
+ <key>RunAtLoad</key>
2430
+ <true/>
2431
+ <key>StandardOutPath</key>
2432
+ <string>${xmlEscape(stdoutPath)}</string>
2433
+ <key>StandardErrorPath</key>
2434
+ <string>${xmlEscape(stderrPath)}</string>
2435
+ <key>EnvironmentVariables</key>
2436
+ <dict>
2437
+ <key>PATH</key>
2438
+ <string>${xmlEscape(launchPath)}</string>
2439
+ </dict>
2440
+ </dict>
2441
+ </plist>
2442
+ `;
2443
+
2444
+ await writeFile(plistPath, plist, { mode: 0o600 });
2445
+ const stdoutOffset = await fileLength(stdoutPath);
2446
+ const stderrOffset = await fileLength(stderrPath);
2447
+ await execFileQuiet("launchctl", ["unload", plistPath], { ignoreError: true });
2448
+ await execFileQuiet("launchctl", ["load", plistPath]);
2449
+ await execFileQuiet("launchctl", ["start", label], { ignoreError: true });
2450
+ await verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset });
2451
+ return { label, plistPath, stdoutPath, stderrPath };
2452
+ }
2453
+
2454
+ async function verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
2455
+ const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
2456
+ for (let attempt = 0; attempt < 8; attempt++) {
2457
+ await sleep(1000);
2458
+ const stdout = await readFileFrom(stdoutPath, stdoutOffset);
2459
+ const stderr = await readFileFrom(stderrPath, stderrOffset);
2460
+ const launchState = readLaunchctlPrint(domainLabel);
2461
+
2462
+ if (/Shepherd Coding Sessions sync starting|Coding session scan/i.test(stdout)
2463
+ && /state = running|job state = running/i.test(launchState)) {
2464
+ return;
2465
+ }
2466
+ if (/EACCES|operation not permitted|permission denied/i.test(stderr)) {
2467
+ throw new Error("Coding-session sync could not read ~/.codex or ~/.claude. Grant the onboarding app access to those folders or run coding-sessions-status for details.");
2468
+ }
2469
+ if (/last exit code = [1-9]|job state = exited|state = spawn scheduled/i.test(launchState) && stderr.trim()) {
2470
+ throw new Error(`Coding-session sync exited during startup: ${firstMeaningfulLine(stderr)}`);
2471
+ }
2472
+ }
2473
+
2474
+ throw new Error("Coding-session sync did not reach a healthy launchd running state. Check logs under ~/.shepherd/coding-sessions and run coding-sessions-status.");
2475
+ }
2476
+
2039
2477
  async function verifyMessagesAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
2040
2478
  const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
2041
2479
  for (let attempt = 0; attempt < 12; attempt++) {
@@ -2121,7 +2559,7 @@ async function selectRecentMessageChats() {
2121
2559
  }
2122
2560
 
2123
2561
  if (!process.stdin.isTTY) {
2124
- throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
2562
+ throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>" or --messages-chat-ids all.`);
2125
2563
  }
2126
2564
 
2127
2565
  const chats = await listRecentMessageChats({ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500) });
@@ -2135,11 +2573,13 @@ async function selectRecentMessageChats() {
2135
2573
 
2136
2574
  console.log(`\nSelect local Messages chats to sync\n`);
2137
2575
  console.log("Shepherd will only pull from the chats you select.");
2576
+ console.log("Enter all to sync every current chat and keep watching future new chats.");
2138
2577
  for (let i = 0; i < chats.length; i++) {
2139
2578
  console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
2140
2579
  }
2141
2580
 
2142
- const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
2581
+ const answer = await prompt("\nEnter chat numbers to sync, separated by commas, or all: ");
2582
+ if (String(answer ?? "").trim().toLowerCase() === "all") return [allMessagesChatsSelection()];
2143
2583
  const indexes = parseSelectionIndexes(answer, chats.length);
2144
2584
  if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
2145
2585
  return indexes.map((idx) => chats[idx]);
@@ -2196,6 +2636,16 @@ async function selectChatsInBrowser(chats, opts = {}) {
2196
2636
  sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
2197
2637
  return;
2198
2638
  }
2639
+ if (form.get("allChats") === "1") {
2640
+ if (!settled) res.once("finish", finishBrowserSelection);
2641
+ sendHtml(res, renderMessagesDonePage("All current and future chats selected."));
2642
+ if (!settled) {
2643
+ settled = true;
2644
+ clearTimeout(timeout);
2645
+ resolve([allMessagesChatsSelection()]);
2646
+ }
2647
+ return;
2648
+ }
2199
2649
  const selectedIds = form.getAll("chatId").filter(Boolean);
2200
2650
  const selectedSet = new Set(selectedIds);
2201
2651
  const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
@@ -2385,6 +2835,31 @@ function renderMessagesSelectorPage(chats, token, error = "") {
2385
2835
  outline-offset: 1px;
2386
2836
  }
2387
2837
  .search::placeholder { color: var(--faint); }
2838
+ .all-chats {
2839
+ display: grid;
2840
+ grid-template-columns: 20px minmax(0, 1fr);
2841
+ gap: 12px;
2842
+ align-items: start;
2843
+ margin-top: 12px;
2844
+ padding: 12px;
2845
+ border: 1px solid var(--line);
2846
+ border-radius: 8px;
2847
+ cursor: pointer;
2848
+ background: #fbfbfa;
2849
+ }
2850
+ .all-chats:hover { border-color: var(--green); }
2851
+ .all-title {
2852
+ display: block;
2853
+ font-size: 14px;
2854
+ font-weight: 600;
2855
+ }
2856
+ .all-copy {
2857
+ display: block;
2858
+ margin-top: 2px;
2859
+ color: var(--muted);
2860
+ font-size: 12.5px;
2861
+ line-height: 1.35;
2862
+ }
2388
2863
  .error {
2389
2864
  margin: 0 0 12px;
2390
2865
  color: #9B1C1C;
@@ -2537,6 +3012,14 @@ function renderMessagesSelectorPage(chats, token, error = "") {
2537
3012
  <div class="panel">
2538
3013
  <div class="panel-head">
2539
3014
  <input class="search" id="search" type="search" placeholder="Search contacts or groups" autocomplete="off">
3015
+ <label class="all-chats">
3016
+ <input type="checkbox" id="all-chats" name="allChats" value="1">
3017
+ <span class="box" aria-hidden="true"></span>
3018
+ <span>
3019
+ <span class="all-title">Sync all current and future chats</span>
3020
+ <span class="all-copy">Backfill every current Messages chat and keep watching chats that appear later.</span>
3021
+ </span>
3022
+ </label>
2540
3023
  </div>
2541
3024
  ${error ? `<p class="error">${html(error)}</p>` : ""}
2542
3025
  <div class="list-head">
@@ -2562,6 +3045,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
2562
3045
  const selected = document.getElementById("selection-count");
2563
3046
  const form = document.querySelector("form");
2564
3047
  const checks = Array.from(document.querySelectorAll('input[name="chatId"]'));
3048
+ const allChats = document.getElementById("all-chats");
2565
3049
 
2566
3050
  function updateRows() {
2567
3051
  const query = search.value.trim().toLowerCase();
@@ -2577,6 +3061,12 @@ function renderMessagesSelectorPage(chats, token, error = "") {
2577
3061
  }
2578
3062
 
2579
3063
  function updateSelected() {
3064
+ if (allChats.checked) {
3065
+ selected.textContent = "All current and future chats";
3066
+ for (const check of checks) check.disabled = true;
3067
+ return;
3068
+ }
3069
+ for (const check of checks) check.disabled = false;
2580
3070
  const count = checks.filter((check) => check.checked).length;
2581
3071
  selected.textContent = count + " selected";
2582
3072
  }
@@ -2587,6 +3077,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
2587
3077
  event.preventDefault();
2588
3078
  form.requestSubmit();
2589
3079
  });
3080
+ allChats.addEventListener("change", updateSelected);
2590
3081
  for (const check of checks) check.addEventListener("change", updateSelected);
2591
3082
  updateRows();
2592
3083
  updateSelected();
@@ -2757,7 +3248,8 @@ async function listRecentMessageChats({ limit }) {
2757
3248
 
2758
3249
  const enriched = [];
2759
3250
  for (const chat of visible) {
2760
- enriched.push(await enrichMessageChat(sdk, chat, contactLookup));
3251
+ const candidate = await enrichMessageChat(sdk, chat, contactLookup);
3252
+ if (!messageChatTouchesShepherdAgent(candidate)) enriched.push(candidate);
2761
3253
  }
2762
3254
  return enriched;
2763
3255
  } finally {
@@ -2765,6 +3257,41 @@ async function listRecentMessageChats({ limit }) {
2765
3257
  }
2766
3258
  }
2767
3259
 
3260
+ async function loadSelectedMessageChatsForConfig(chatIds) {
3261
+ const allowedChatIds = parseAllowedChatIds(chatIds);
3262
+ if (allowedChatIds.length === 0 || selectedChatIdsIncludeAll(allowedChatIds)) return [];
3263
+ if (platform() !== "darwin") return [];
3264
+
3265
+ const kit = await import("@photon-ai/imessage-kit");
3266
+ const sdk = new kit.IMessageSDK({ debug: args.debug === true });
3267
+ const contactLookup = buildContactLookup();
3268
+ try {
3269
+ const listedChats = typeof sdk.listChats === "function"
3270
+ ? await sdk.listChats({ sortBy: "recent", limit: 5_000 }).catch(() => [])
3271
+ : [];
3272
+ const byId = new Map(listedChats
3273
+ .filter((chat) => typeof chat?.chatId === "string")
3274
+ .map((chat) => [chat.chatId, chat]));
3275
+
3276
+ const selected = [];
3277
+ for (const chatId of allowedChatIds) {
3278
+ const listed = byId.get(chatId);
3279
+ const fallback = {
3280
+ chatId,
3281
+ kind: parseDmHandleFromChatId(chatId) ? "dm" : "group",
3282
+ name: null,
3283
+ service: null,
3284
+ lastMessageAt: null,
3285
+ };
3286
+ const candidate = await enrichMessageChat(sdk, { ...fallback, ...listed, chatId }, contactLookup);
3287
+ if (!messageChatTouchesShepherdAgent(candidate)) selected.push(candidate);
3288
+ }
3289
+ return selected;
3290
+ } finally {
3291
+ await sdk.close?.().catch(() => undefined);
3292
+ }
3293
+ }
3294
+
2768
3295
  async function enrichMessageChat(sdk, chat, contactLookup) {
2769
3296
  const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
2770
3297
  const participants = uniqueParticipants(recentMessages, contactLookup);
@@ -2795,7 +3322,7 @@ function uniqueParticipants(messages, contactLookup) {
2795
3322
  const participants = [];
2796
3323
  for (const msg of messages) {
2797
3324
  const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
2798
- if (!handle || contactLookup.isSelfHandle(handle)) continue;
3325
+ if (!handle || contactLookup.isSelfHandle(handle) || isShepherdAgentMessageHandle(handle)) continue;
2799
3326
  const normalized = normalizeHandle(handle);
2800
3327
  if (seen.has(normalized)) continue;
2801
3328
  seen.add(normalized);
@@ -2825,6 +3352,21 @@ function publicMessageChat(chat) {
2825
3352
  };
2826
3353
  }
2827
3354
 
3355
+ function allMessagesChatsSelection() {
3356
+ return {
3357
+ chatId: ALL_MESSAGES_CHATS,
3358
+ label: "All current and future chats",
3359
+ kind: "all",
3360
+ service: null,
3361
+ lastMessageAt: null,
3362
+ participants: [],
3363
+ };
3364
+ }
3365
+
3366
+ function selectedIncludesAllChats(chats) {
3367
+ return Array.isArray(chats) && chats.some((chat) => chat?.chatId === ALL_MESSAGES_CHATS);
3368
+ }
3369
+
2828
3370
  async function loadGroupChatNames(sdk, serializer) {
2829
3371
  if (typeof sdk.listChats !== "function") return;
2830
3372
  try {
@@ -2847,21 +3389,67 @@ function loadSelectedChatNames(selectedChats, serializer) {
2847
3389
  }
2848
3390
  }
2849
3391
 
2850
- async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds) {
2851
- console.log(`Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for ${allowedChatIds.length} selected chat(s)`);
3392
+ function selectedChatContactSeedHandles(selectedChats, allowedChatIds) {
3393
+ const handles = [];
3394
+ for (const chatId of allowedChatIds ?? []) {
3395
+ const dmHandle = parseDmHandleFromChatId(chatId);
3396
+ if (dmHandle && !isShepherdAgentMessageHandle(dmHandle)) handles.push(dmHandle);
3397
+ }
3398
+ for (const chat of Array.isArray(selectedChats) ? selectedChats : []) {
3399
+ for (const participant of Array.isArray(chat?.participants) ? chat.participants : []) {
3400
+ if (
3401
+ typeof participant?.handle === "string"
3402
+ && participant.handle.trim()
3403
+ && !isShepherdAgentMessageHandle(participant.handle)
3404
+ ) {
3405
+ handles.push(participant.handle);
3406
+ }
3407
+ }
3408
+ }
3409
+ return [...new Set(handles)];
3410
+ }
3411
+
3412
+ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null) {
3413
+ const allChats = allowedChatIds == null;
3414
+ console.log(allChats
3415
+ ? `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for all current chats`
3416
+ : `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for ${allowedChatIds.length} selected chat(s)`);
2852
3417
  const since = days == null ? null : new Date(Date.now() - days * 24 * 60 * 60 * 1000);
2853
3418
  const pageSize = 1000;
2854
3419
  let totalMessages = 0;
2855
3420
  let totalStored = 0;
2856
3421
 
3422
+ if (allChats) {
3423
+ let offset = 0;
3424
+ while (true) {
3425
+ const messages = await sdk.getMessages({ ...(since ? { since } : {}), limit: pageSize, offset });
3426
+ if (!messages.length) break;
3427
+ const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
3428
+
3429
+ contactSync?.observeMessages(filtered);
3430
+ totalMessages += filtered.length;
3431
+ const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
3432
+ totalStored += result.stored;
3433
+ saveMessagesWatermark(sender.userId, maxRowId(messages));
3434
+
3435
+ if (messages.length < pageSize) break;
3436
+ offset += pageSize;
3437
+ }
3438
+
3439
+ console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
3440
+ return;
3441
+ }
3442
+
2857
3443
  for (const chatId of allowedChatIds) {
2858
3444
  let offset = 0;
2859
3445
  while (true) {
2860
3446
  const messages = await sdk.getMessages({ chatId, ...(since ? { since } : {}), limit: pageSize, offset });
2861
3447
  if (!messages.length) break;
3448
+ const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
2862
3449
 
2863
- totalMessages += messages.length;
2864
- const result = await sender.send(messages.map((msg) => serializer.serialize(msg)));
3450
+ contactSync?.observeMessages(filtered);
3451
+ totalMessages += filtered.length;
3452
+ const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
2865
3453
  totalStored += result.stored;
2866
3454
  saveMessagesWatermark(sender.userId, maxRowId(messages));
2867
3455
 
@@ -2873,24 +3461,35 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
2873
3461
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
2874
3462
  }
2875
3463
 
2876
- async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
3464
+ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null) {
3465
+ const allChats = allowedChatIds == null;
2877
3466
  const lastWatermark = loadMessagesWatermark(userId);
2878
3467
  if (lastWatermark <= 0) return;
2879
3468
 
2880
3469
  const missed = [];
2881
- for (const chatId of allowedChatIds) {
2882
- missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
3470
+ if (allChats) {
3471
+ missed.push(...await sdk.getMessages({ limit: 5000 }));
3472
+ } else {
3473
+ for (const chatId of allowedChatIds) {
3474
+ missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
3475
+ }
2883
3476
  }
2884
- const newMessages = missed.filter((msg) => Number(msg.rowId) > lastWatermark && allowedChatIds.includes(msg.chatId));
3477
+ const newMessages = missed.filter((msg) =>
3478
+ Number(msg.rowId) > lastWatermark
3479
+ && (allChats || allowedChatIds.includes(msg.chatId))
3480
+ && !messageTouchesShepherdAgent(msg));
2885
3481
  if (newMessages.length === 0) return;
2886
3482
 
3483
+ contactSync?.observeMessages(newMessages);
2887
3484
  const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
2888
3485
  if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(newMessages));
2889
3486
  console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
2890
3487
  }
2891
3488
 
2892
- async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
2893
- const allowed = new Set(allowedChatIds);
3489
+ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, opts = {}) {
3490
+ const allChats = allowedChatIds == null;
3491
+ const allowed = new Set(allowedChatIds ?? []);
3492
+ const contactSync = opts.contactSync ?? null;
2894
3493
  let buffer = [];
2895
3494
  let timer = null;
2896
3495
 
@@ -2910,7 +3509,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
2910
3509
  };
2911
3510
 
2912
3511
  const onMessage = (msg) => {
2913
- if (!msg.chatId || !allowed.has(msg.chatId)) return;
3512
+ if (!msg.chatId || (!allChats && !allowed.has(msg.chatId))) return;
3513
+ if (messageTouchesShepherdAgent(msg)) return;
3514
+ contactSync?.observeMessages([msg]);
2914
3515
  buffer.push(msg);
2915
3516
  if (buffer.length >= MAX_BATCH_SIZE) {
2916
3517
  if (timer) clearTimeout(timer);
@@ -2927,7 +3528,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
2927
3528
  onError: (err) => console.error("Messages watcher error:", safeError(err)),
2928
3529
  });
2929
3530
 
2930
- console.log("Watching for new Messages in selected chats");
3531
+ console.log(allChats
3532
+ ? "Watching for new Messages in all current and future chats"
3533
+ : "Watching for new Messages in selected chats");
2931
3534
 
2932
3535
  await new Promise((resolve) => {
2933
3536
  let stopping = false;
@@ -2935,6 +3538,7 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
2935
3538
  if (stopping) return;
2936
3539
  stopping = true;
2937
3540
  if (timer) clearTimeout(timer);
3541
+ contactSync?.stop();
2938
3542
  await flush().catch(() => undefined);
2939
3543
  await sdk.close?.().catch(() => undefined);
2940
3544
  resolve();
@@ -2945,15 +3549,170 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
2945
3549
  });
2946
3550
  }
2947
3551
 
2948
- function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
2949
- const chatNames = new Map();
2950
- const isImageAttachment = kit.isImageAttachment ?? (() => false);
2951
- const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
2952
- const isAudioAttachment = kit.isAudioAttachment ?? (() => false);
2953
-
3552
+ function createMutableContactLookup(initial = emptyContactLookup()) {
3553
+ let current = initial;
2954
3554
  return {
2955
- setChatName(chatId, name) {
2956
- chatNames.set(chatId, name);
3555
+ replace(next) {
3556
+ current = next ?? emptyContactLookup();
3557
+ },
3558
+ resolveName(handle) {
3559
+ return current.resolveName(handle);
3560
+ },
3561
+ isSelfHandle(handle) {
3562
+ return current.isSelfHandle(handle);
3563
+ },
3564
+ mappings() {
3565
+ return contactMappingsFromLookup(current);
3566
+ },
3567
+ };
3568
+ }
3569
+
3570
+ function startMessagesContactSync(sender, contactLookup, opts = {}) {
3571
+ const syncAllContacts = opts.syncAllContacts === true;
3572
+ const observedHandles = new Set();
3573
+ for (const handle of opts.seedHandles ?? []) rememberObservedHandle(observedHandles, contactLookup, handle);
3574
+
3575
+ let previousSnapshot = new Map();
3576
+ let syncTimer = null;
3577
+ let contactsWatcher = null;
3578
+ let fallbackInterval = null;
3579
+ let syncing = false;
3580
+ let pendingReason = null;
3581
+ let stopped = false;
3582
+
3583
+ const visibleMappings = () => {
3584
+ const mappings = contactLookup.mappings();
3585
+ if (syncAllContacts) return mappings;
3586
+ return mappings.filter((mapping) => handleCandidates(mapping.handle).some((candidate) => observedHandles.has(candidate)));
3587
+ };
3588
+
3589
+ const syncNow = async ({ forceAll = false, reason = "manual" } = {}) => {
3590
+ if (stopped) return { skipped: true, reason: "stopped" };
3591
+ if (syncing) {
3592
+ pendingReason = reason;
3593
+ return { skipped: true, reason: "already_syncing" };
3594
+ }
3595
+
3596
+ syncing = true;
3597
+ try {
3598
+ const nextLookup = buildContactLookup();
3599
+ contactLookup.replace(nextLookup);
3600
+ const mappings = visibleMappings();
3601
+ const toSync = forceAll
3602
+ ? mappings
3603
+ : mappings.filter((mapping) => previousSnapshot.get(mapping.handle) !== mapping.name);
3604
+
3605
+ if (toSync.length === 0) {
3606
+ previousSnapshot = snapshotContactMappings(mappings);
3607
+ return { updated: 0, contactsProcessed: 0 };
3608
+ }
3609
+
3610
+ const result = await sender.syncContacts(toSync);
3611
+ previousSnapshot = snapshotContactMappings(mappings);
3612
+ console.log(`[contacts] Synced ${toSync.length}/${mappings.length} Messages contact mappings (${reason}); updated ${result.updated ?? 0}`);
3613
+ return result;
3614
+ } finally {
3615
+ syncing = false;
3616
+ const queuedReason = pendingReason;
3617
+ pendingReason = null;
3618
+ if (queuedReason && !stopped) scheduleSync(queuedReason);
3619
+ }
3620
+ };
3621
+
3622
+ const scheduleSync = (reason) => {
3623
+ if (stopped) return;
3624
+ if (syncTimer) clearTimeout(syncTimer);
3625
+ syncTimer = setTimeout(() => {
3626
+ syncTimer = null;
3627
+ syncNow({ reason }).catch((err) => console.error("Messages contact sync failed:", safeError(err)));
3628
+ }, CONTACT_SYNC_DEBOUNCE_MS);
3629
+ };
3630
+
3631
+ const observeMessages = (messages) => {
3632
+ let changed = false;
3633
+ for (const msg of messages ?? []) {
3634
+ if (rememberObservedHandle(observedHandles, contactLookup, msg?.participant)) changed = true;
3635
+ if (msg?.affectedParticipant) {
3636
+ if (rememberObservedHandle(observedHandles, contactLookup, msg.affectedParticipant)) changed = true;
3637
+ }
3638
+ }
3639
+ if (changed) scheduleSync("observed-message-handle");
3640
+ };
3641
+
3642
+ const contactWatchPaths = addressBookWalPaths();
3643
+ if (contactWatchPaths.length > 0) {
3644
+ contactsWatcher = [];
3645
+ for (const path of contactWatchPaths) {
3646
+ try {
3647
+ contactsWatcher.push(watch(path, () => scheduleSync("contacts-watch")));
3648
+ } catch (err) {
3649
+ console.warn(`Could not watch Contacts WAL ${path}: ${safeError(err)}`);
3650
+ }
3651
+ }
3652
+ if (contactsWatcher.length > 0) {
3653
+ console.log(`Watching ${contactsWatcher.length} Contacts database WAL file(s) for Messages name changes`);
3654
+ } else {
3655
+ console.warn("Contacts WAL files were found but could not be watched; Messages contact sync will use fallback polling only");
3656
+ }
3657
+ } else if (platform() === "darwin") {
3658
+ console.warn("Contacts WAL not found; Messages contact sync will use fallback polling only");
3659
+ }
3660
+
3661
+ fallbackInterval = setInterval(() => {
3662
+ syncNow({ reason: "fallback" }).catch((err) => console.error("Messages contact fallback sync failed:", safeError(err)));
3663
+ }, CONTACT_SYNC_FALLBACK_MS);
3664
+
3665
+ return {
3666
+ observeMessages,
3667
+ syncNow,
3668
+ stop() {
3669
+ stopped = true;
3670
+ if (syncTimer) clearTimeout(syncTimer);
3671
+ if (fallbackInterval) clearInterval(fallbackInterval);
3672
+ if (contactsWatcher) {
3673
+ for (const watcher of contactsWatcher) watcher.close();
3674
+ }
3675
+ },
3676
+ };
3677
+ }
3678
+
3679
+ function rememberObservedHandle(observedHandles, contactLookup, handle) {
3680
+ const raw = typeof handle === "string" ? handle.trim() : "";
3681
+ if (!raw || contactLookup.isSelfHandle(raw)) return false;
3682
+ let changed = false;
3683
+ for (const candidate of handleCandidates(raw)) {
3684
+ if (!observedHandles.has(candidate)) {
3685
+ observedHandles.add(candidate);
3686
+ changed = true;
3687
+ }
3688
+ }
3689
+ return changed;
3690
+ }
3691
+
3692
+ function contactMappingsFromLookup(lookup) {
3693
+ const mappings = typeof lookup?.mappings === "function" ? lookup.mappings() : lookup?.mappings;
3694
+ if (!Array.isArray(mappings)) return [];
3695
+ return mappings.filter((mapping) =>
3696
+ mapping
3697
+ && typeof mapping.handle === "string"
3698
+ && mapping.handle.trim()
3699
+ && typeof mapping.name === "string"
3700
+ && mapping.name.trim());
3701
+ }
3702
+
3703
+ function snapshotContactMappings(mappings) {
3704
+ return new Map(mappings.map((mapping) => [mapping.handle, mapping.name]));
3705
+ }
3706
+
3707
+ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
3708
+ const chatNames = new Map();
3709
+ const isImageAttachment = kit.isImageAttachment ?? (() => false);
3710
+ const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
3711
+ const isAudioAttachment = kit.isAudioAttachment ?? (() => false);
3712
+
3713
+ return {
3714
+ setChatName(chatId, name) {
3715
+ chatNames.set(chatId, name);
2957
3716
  },
2958
3717
  serialize(msg) {
2959
3718
  const chatId = msg.chatId ?? "unknown";
@@ -3039,6 +3798,9 @@ function buildContactLookup(opts = {}) {
3039
3798
  isSelfHandle(handle) {
3040
3799
  return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
3041
3800
  },
3801
+ mappings() {
3802
+ return [...handleToName.entries()].map(([handle, name]) => ({ handle, name }));
3803
+ },
3042
3804
  };
3043
3805
  }
3044
3806
 
@@ -3050,6 +3812,9 @@ function emptyContactLookup() {
3050
3812
  isSelfHandle() {
3051
3813
  return false;
3052
3814
  },
3815
+ mappings() {
3816
+ return [];
3817
+ },
3053
3818
  };
3054
3819
  }
3055
3820
 
@@ -3146,6 +3911,15 @@ function addressBookDatabasePaths() {
3146
3911
  }
3147
3912
  }
3148
3913
 
3914
+ function addressBookWalPaths() {
3915
+ if (platform() !== "darwin") return [];
3916
+ const paths = new Set([CONTACTS_WAL_PATH]);
3917
+ for (const dbPath of addressBookDatabasePaths()) {
3918
+ paths.add(`${dbPath}-wal`);
3919
+ }
3920
+ return [...paths].filter((path) => existsSync(path));
3921
+ }
3922
+
3149
3923
  function loadMyCard() {
3150
3924
  if (platform() !== "darwin") return null;
3151
3925
  const script = `
@@ -3260,6 +4034,40 @@ function parseDmHandleFromChatId(chatId) {
3260
4034
  return null;
3261
4035
  }
3262
4036
 
4037
+ function messageChatTouchesShepherdAgent(chat) {
4038
+ if (isShepherdAgentMessageHandle(parseDmHandleFromChatId(chat?.chatId))) return true;
4039
+ return Array.isArray(chat?.participants)
4040
+ && chat.participants.some((participant) => isShepherdAgentMessageHandle(participant?.handle));
4041
+ }
4042
+
4043
+ function messageTouchesShepherdAgent(msg) {
4044
+ return isShepherdAgentMessageHandle(msg?.participant)
4045
+ || isShepherdAgentMessageHandle(msg?.affectedParticipant)
4046
+ || isShepherdAgentMessageHandle(parseDmHandleFromChatId(msg?.chatId));
4047
+ }
4048
+
4049
+ function isShepherdAgentMessageHandle(handle) {
4050
+ if (!SHEPHERD_OWNED_MESSAGE_HANDLES.length) return false;
4051
+ const blocked = new Set(SHEPHERD_OWNED_MESSAGE_HANDLES.flatMap(handleCandidates));
4052
+ return handleCandidates(handle).some((candidate) => blocked.has(candidate));
4053
+ }
4054
+
4055
+ function parseMessageHandleList(value) {
4056
+ return String(value ?? "")
4057
+ .split(",")
4058
+ .map((handle) => handle.trim())
4059
+ .filter(Boolean);
4060
+ }
4061
+
4062
+ function mergeShepherdOwnedMessageHandles(value) {
4063
+ for (const handle of Array.isArray(value) ? value : parseMessageHandleList(value)) {
4064
+ if (typeof handle !== "string" || !handle.trim()) continue;
4065
+ if (!SHEPHERD_OWNED_MESSAGE_HANDLES.includes(handle.trim())) {
4066
+ SHEPHERD_OWNED_MESSAGE_HANDLES.push(handle.trim());
4067
+ }
4068
+ }
4069
+ }
4070
+
3263
4071
  function parseSelectionIndexes(answer, max) {
3264
4072
  const indexes = new Set();
3265
4073
  for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
@@ -3285,7 +4093,15 @@ function parseMessageChatIdsArg() {
3285
4093
  function parseAllowedChatIds(value) {
3286
4094
  if (!value) return [];
3287
4095
  const raw = Array.isArray(value) ? value : String(value).split(",");
3288
- return [...new Set(raw.map((chatId) => String(chatId).trim()).filter(Boolean))];
4096
+ const values = raw.map((chatId) => String(chatId).trim()).filter(Boolean);
4097
+ if (values.some((chatId) => chatId.toLowerCase() === "all" || chatId === ALL_MESSAGES_CHATS)) {
4098
+ return [ALL_MESSAGES_CHATS];
4099
+ }
4100
+ return [...new Set(values)];
4101
+ }
4102
+
4103
+ function selectedChatIdsIncludeAll(chatIds) {
4104
+ return Array.isArray(chatIds) && chatIds.includes(ALL_MESSAGES_CHATS);
3289
4105
  }
3290
4106
 
3291
4107
  function html(value) {
@@ -3299,6 +4115,513 @@ function htmlAttr(value) {
3299
4115
  return html(value).replace(/"/g, "&quot;");
3300
4116
  }
3301
4117
 
4118
+ async function scanCodingSessions(config, previousState = { hashes: {} }) {
4119
+ const probes = await probeCodingSessionPaths(config);
4120
+ const errors = [];
4121
+ const sessions = [];
4122
+ const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
4123
+ const claudeDir = typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR;
4124
+ const maxFiles = Math.max(1, Math.floor(Number(config.maxFilesPerProvider ?? 300)));
4125
+
4126
+ for (const dir of codexDirs) {
4127
+ try {
4128
+ const files = await recentJsonlFiles(dir, maxFiles);
4129
+ for (const file of files) {
4130
+ const parsed = await parseCodexSessionFile(file, config).catch((err) => {
4131
+ errors.push({ provider: "codex", pathHash: hashString(file), error: safeError(err) });
4132
+ return null;
4133
+ });
4134
+ if (parsed) sessions.push(parsed);
4135
+ }
4136
+ } catch (err) {
4137
+ if (args.debug) errors.push({ provider: "codex", pathHash: hashString(dir), error: safeError(err) });
4138
+ }
4139
+ }
4140
+
4141
+ try {
4142
+ const files = await recentJsonlFiles(claudeDir, maxFiles);
4143
+ for (const file of files) {
4144
+ const parsed = await parseClaudeSessionFile(file, config).catch((err) => {
4145
+ errors.push({ provider: "claude", pathHash: hashString(file), error: safeError(err) });
4146
+ return null;
4147
+ });
4148
+ if (parsed) sessions.push(parsed);
4149
+ }
4150
+ } catch (err) {
4151
+ if (args.debug) errors.push({ provider: "claude", pathHash: hashString(claudeDir), error: safeError(err) });
4152
+ }
4153
+
4154
+ return {
4155
+ probes,
4156
+ errors,
4157
+ sessions: sessions
4158
+ .sort((a, b) => String(b.lastUpdatedAt ?? "").localeCompare(String(a.lastUpdatedAt ?? "")))
4159
+ .slice(0, maxFiles * 2),
4160
+ previousState,
4161
+ };
4162
+ }
4163
+
4164
+ async function probeCodingSessionPaths(config) {
4165
+ const probes = [];
4166
+ const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
4167
+ for (const path of codexDirs) probes.push(await probePath("codex", path));
4168
+ probes.push(await probePath("claude", typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR));
4169
+ return probes;
4170
+ }
4171
+
4172
+ async function probePath(provider, path) {
4173
+ try {
4174
+ await access(path, fsConstants.R_OK);
4175
+ const info = await stat(path);
4176
+ return { provider, path, readable: true, directory: info.isDirectory() };
4177
+ } catch (err) {
4178
+ return { provider, path, readable: false, reason: err?.code ?? safeError(err) };
4179
+ }
4180
+ }
4181
+
4182
+ async function recentJsonlFiles(root, limit) {
4183
+ const files = [];
4184
+ await walkJsonl(root, files, limit * 8);
4185
+ return files
4186
+ .sort((a, b) => b.mtimeMs - a.mtimeMs)
4187
+ .slice(0, limit)
4188
+ .map((file) => file.path);
4189
+ }
4190
+
4191
+ async function walkJsonl(dir, files, cap) {
4192
+ if (files.length >= cap) return;
4193
+ const entries = await readdir(dir, { withFileTypes: true });
4194
+ for (const entry of entries) {
4195
+ if (files.length >= cap) return;
4196
+ const path = join(dir, entry.name);
4197
+ if (entry.isDirectory()) {
4198
+ await walkJsonl(path, files, cap).catch(() => undefined);
4199
+ } else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
4200
+ const info = await stat(path).catch(() => null);
4201
+ if (info) files.push({ path, mtimeMs: info.mtimeMs });
4202
+ }
4203
+ }
4204
+ }
4205
+
4206
+ async function parseCodexSessionFile(path, config) {
4207
+ const lines = await readJsonlRecords(path);
4208
+ if (lines.length === 0) return null;
4209
+ const payloads = lines.map((line) => line.payload && typeof line.payload === "object" ? line.payload : line);
4210
+ const meta = payloads.find((payload) => payload && typeof payload === "object" && (payload.id || payload.started_at || payload.cwd)) ?? {};
4211
+ const timestamps = lines.map((line) => isoDate(line.timestamp ?? line.payload?.timestamp ?? line.payload?.started_at)).filter(Boolean);
4212
+ const cwd = firstString(payloads, ["cwd"]);
4213
+ const repo = await repoMetadata(cwd);
4214
+ const actor = await actorMetadata(cwd, config);
4215
+ const userTexts = extractTextValues(payloads, ["text", "query"]).filter((text) => text.length > 0);
4216
+ const summaries = extractTextValues(payloads, ["summary", "formatted_output"]).filter((text) => text.length > 0);
4217
+ const commands = extractCodexCommands(payloads);
4218
+ const session = {
4219
+ provider: "codex",
4220
+ sessionId: cleanSessionId(String(meta.id ?? basename(path, ".jsonl"))),
4221
+ startedAt: minIso(timestamps) ?? isoDate(meta.started_at),
4222
+ endedAt: maxIso(timestamps),
4223
+ lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
4224
+ status: sessionStatus(maxIso(timestamps)),
4225
+ actor,
4226
+ repo,
4227
+ cwdHash: cwd ? hashString(cwd) : null,
4228
+ cwdBasename: cwd ? basename(cwd) : null,
4229
+ title: redactText(firstUseful(userTexts) ?? firstUseful(summaries) ?? basename(path, ".jsonl"), 180),
4230
+ goal: redactText(firstUseful(userTexts), 600),
4231
+ summary: buildLocalSessionSummary("Codex", userTexts, summaries, commands),
4232
+ decisions: [],
4233
+ filesTouched: inferFilesTouched([...userTexts, ...summaries, ...commands.map((command) => command.command)]),
4234
+ commands,
4235
+ verification: inferVerification(commands),
4236
+ errors: inferErrors(commands, summaries),
4237
+ followUps: inferFollowUps([...userTexts, ...summaries]),
4238
+ omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
4239
+ sourcePathHash: hashString(path),
4240
+ };
4241
+ session.contentHash = hashObject(session);
4242
+ return session;
4243
+ }
4244
+
4245
+ async function parseClaudeSessionFile(path, config) {
4246
+ const lines = await readJsonlRecords(path);
4247
+ if (lines.length === 0) return null;
4248
+ const timestamps = lines.map((line) => isoDate(line.timestamp)).filter(Boolean);
4249
+ const cwd = firstString(lines, ["cwd"]);
4250
+ const repo = await repoMetadata(cwd);
4251
+ const actor = await actorMetadata(cwd, config);
4252
+ const sessionId = firstString(lines, ["sessionId"]) ?? basename(path, ".jsonl");
4253
+ const title = firstString(lines, ["aiTitle", "lastPrompt", "slug"]) ?? firstMessageText(lines);
4254
+ const userTexts = claudeTextValues(lines, "user");
4255
+ const assistantTexts = claudeTextValues(lines, "assistant");
4256
+ const commands = extractClaudeCommands(lines);
4257
+ const session = {
4258
+ provider: "claude",
4259
+ sessionId: cleanSessionId(sessionId),
4260
+ startedAt: minIso(timestamps),
4261
+ endedAt: maxIso(timestamps),
4262
+ lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
4263
+ status: sessionStatus(maxIso(timestamps)),
4264
+ actor,
4265
+ repo: {
4266
+ ...repo,
4267
+ branch: repo.branch ?? firstString(lines, ["gitBranch"]),
4268
+ },
4269
+ cwdHash: cwd ? hashString(cwd) : null,
4270
+ cwdBasename: cwd ? basename(cwd) : null,
4271
+ title: redactText(title, 180),
4272
+ goal: redactText(firstUseful(userTexts), 600),
4273
+ summary: buildLocalSessionSummary("Claude Code", userTexts, assistantTexts, commands),
4274
+ decisions: [],
4275
+ filesTouched: inferFilesTouched([...userTexts, ...assistantTexts, ...commands.map((command) => command.command)]),
4276
+ commands,
4277
+ verification: inferVerification(commands),
4278
+ errors: inferErrors(commands, assistantTexts),
4279
+ followUps: inferFollowUps([...userTexts, ...assistantTexts]),
4280
+ omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
4281
+ sourcePathHash: hashString(path),
4282
+ };
4283
+ session.contentHash = hashObject(session);
4284
+ return session;
4285
+ }
4286
+
4287
+ async function readJsonlRecords(path) {
4288
+ const raw = await readFile(path, "utf8");
4289
+ return raw
4290
+ .split("\n")
4291
+ .filter((line) => line.trim())
4292
+ .slice(-3000)
4293
+ .flatMap((line) => {
4294
+ try {
4295
+ return [JSON.parse(line)];
4296
+ } catch {
4297
+ return [];
4298
+ }
4299
+ });
4300
+ }
4301
+
4302
+ function extractCodexCommands(payloads) {
4303
+ return payloads.flatMap((payload) => {
4304
+ if (!payload || typeof payload !== "object") return [];
4305
+ const command = payload.command ?? payload.parsed_cmd?.join?.(" ") ?? null;
4306
+ if (typeof command !== "string" || !command.trim()) return [];
4307
+ return [{
4308
+ command: redactText(command, 600),
4309
+ exitCode: typeof payload.exit_code === "number" ? payload.exit_code : null,
4310
+ summary: payload.stdout || payload.stderr
4311
+ ? redactText(String(payload.stderr || payload.stdout).split("\n").find(Boolean) ?? "", 240)
4312
+ : null,
4313
+ }];
4314
+ }).slice(-100);
4315
+ }
4316
+
4317
+ function extractClaudeCommands(lines) {
4318
+ const commands = [];
4319
+ for (const line of lines) {
4320
+ const text = JSON.stringify(line.toolUseResult ?? line.message?.content ?? "");
4321
+ const bashMatch = text.match(/(?:"command"|"input")\s*:\s*"([^"]{2,500})"/);
4322
+ if (bashMatch && /\b(?:git|npm|pnpm|yarn|bun|pytest|vitest|cargo|go|python|node|tsc|ruff|eslint|make)\b/.test(bashMatch[1])) {
4323
+ commands.push({ command: redactText(unescapeJsonString(bashMatch[1]), 600), exitCode: null, summary: null });
4324
+ }
4325
+ }
4326
+ return commands.slice(-100);
4327
+ }
4328
+
4329
+ async function repoMetadata(cwd) {
4330
+ const base = { fullName: null, remote: null, branch: null, commit: null };
4331
+ if (!cwd) return base;
4332
+ const remote = await gitValue(cwd, ["config", "--get", "remote.origin.url"]);
4333
+ const branch = await gitValue(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
4334
+ const commit = await gitValue(cwd, ["rev-parse", "--short", "HEAD"]);
4335
+ return {
4336
+ fullName: repoFullNameFromRemote(remote),
4337
+ remote: remote ? redactCredentialUrl(remote) : null,
4338
+ branch,
4339
+ commit,
4340
+ };
4341
+ }
4342
+
4343
+ async function actorMetadata(cwd, config) {
4344
+ const email = config.actorEmail ?? await gitValue(cwd, ["config", "user.email"]) ?? await gitValue(null, ["config", "--global", "user.email"]);
4345
+ const name = config.actorName ?? await gitValue(cwd, ["config", "user.name"]) ?? await gitValue(null, ["config", "--global", "user.name"]);
4346
+ return { email: email ?? null, name: name ?? null };
4347
+ }
4348
+
4349
+ async function gitValue(cwd, argv) {
4350
+ return new Promise((resolve) => {
4351
+ execFile("git", argv, { cwd: cwd || undefined, timeout: 2000, windowsHide: true }, (error, stdout) => {
4352
+ if (error) resolve(null);
4353
+ else resolve(String(stdout).trim() || null);
4354
+ });
4355
+ });
4356
+ }
4357
+
4358
+ function buildLocalSessionSummary(provider, userTexts, assistantTexts, commands) {
4359
+ const primary = firstUseful([...userTexts, ...assistantTexts]);
4360
+ const commandSummary = commands.length ? ` Commands included: ${commands.slice(-5).map((command) => command.command).join("; ")}.` : "";
4361
+ return redactText(`${provider} session${primary ? `: ${primary}` : " synced."}${commandSummary}`, 1200);
4362
+ }
4363
+
4364
+ function firstUseful(values) {
4365
+ return values.find((value) => value && value.trim().length > 8) ?? null;
4366
+ }
4367
+
4368
+ function firstString(records, keys) {
4369
+ for (const record of records) {
4370
+ if (!record || typeof record !== "object") continue;
4371
+ for (const key of keys) {
4372
+ const value = record[key];
4373
+ if (typeof value === "string" && value.trim()) return value.trim();
4374
+ }
4375
+ }
4376
+ return null;
4377
+ }
4378
+
4379
+ function extractTextValues(records, keys) {
4380
+ const values = [];
4381
+ for (const record of records) {
4382
+ if (!record || typeof record !== "object") continue;
4383
+ for (const key of keys) {
4384
+ const value = record[key];
4385
+ if (typeof value === "string" && value.trim()) values.push(redactText(value, 1200));
4386
+ }
4387
+ }
4388
+ return values;
4389
+ }
4390
+
4391
+ function claudeTextValues(records, role) {
4392
+ const values = [];
4393
+ for (const record of records) {
4394
+ const message = record?.message;
4395
+ if (!message || typeof message !== "object" || message.role !== role) continue;
4396
+ const content = message.content;
4397
+ if (typeof content === "string") values.push(redactText(content, 1200));
4398
+ if (Array.isArray(content)) {
4399
+ for (const item of content) {
4400
+ if (item?.type === "text" && typeof item.text === "string") values.push(redactText(item.text, 1200));
4401
+ }
4402
+ }
4403
+ }
4404
+ return values;
4405
+ }
4406
+
4407
+ function firstMessageText(records) {
4408
+ return firstUseful(claudeTextValues(records, "user")) ?? firstUseful(claudeTextValues(records, "assistant"));
4409
+ }
4410
+
4411
+ function inferFilesTouched(values) {
4412
+ const matches = new Set();
4413
+ const fileRe = /\b(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|py|rs|go|swift|kt|java|json|md|sql|css|html|yml|yaml|toml)\b/g;
4414
+ for (const value of values) {
4415
+ for (const match of String(value ?? "").matchAll(fileRe)) matches.add(match[0]);
4416
+ }
4417
+ return [...matches].slice(0, 200);
4418
+ }
4419
+
4420
+ function inferVerification(commands) {
4421
+ return commands
4422
+ .filter((command) => /\b(?:test|vitest|jest|pytest|tsc|lint|eslint|build|cargo test|go test)\b/i.test(command.command))
4423
+ .map((command) => `${command.command}${command.exitCode == null ? "" : ` exited ${command.exitCode}`}`)
4424
+ .slice(0, 30);
4425
+ }
4426
+
4427
+ function inferErrors(commands, texts) {
4428
+ const errors = [];
4429
+ for (const command of commands) {
4430
+ if (typeof command.exitCode === "number" && command.exitCode !== 0) errors.push(`${command.command} exited ${command.exitCode}`);
4431
+ }
4432
+ for (const text of texts) {
4433
+ if (/\b(?:error|failed|failing|blocked|exception)\b/i.test(text)) errors.push(redactText(text, 280));
4434
+ }
4435
+ return [...new Set(errors)].slice(0, 30);
4436
+ }
4437
+
4438
+ function inferFollowUps(texts) {
4439
+ return texts
4440
+ .filter((text) => /\b(?:todo|follow up|next|later|needs?|remaining)\b/i.test(text))
4441
+ .map((text) => redactText(text, 280))
4442
+ .slice(0, 30);
4443
+ }
4444
+
4445
+ function sessionStatus(lastUpdatedAt) {
4446
+ if (!lastUpdatedAt) return "unknown";
4447
+ return Date.now() - Date.parse(lastUpdatedAt) < 5 * 60_000 ? "active" : "settled";
4448
+ }
4449
+
4450
+ function minIso(values) {
4451
+ return values.length ? values.sort()[0] : null;
4452
+ }
4453
+
4454
+ function maxIso(values) {
4455
+ return values.length ? values.sort().at(-1) : null;
4456
+ }
4457
+
4458
+ function cleanSessionId(value) {
4459
+ return String(value ?? "").replace(/[^a-zA-Z0-9_.:-]/g, "-").slice(0, 200) || hashString(value).slice(0, 16);
4460
+ }
4461
+
4462
+ function repoFullNameFromRemote(remote) {
4463
+ if (!remote) return null;
4464
+ const match = String(remote).match(/[:/]([^/:]+\/[^/.]+)(?:\.git)?$/);
4465
+ return match?.[1] ?? null;
4466
+ }
4467
+
4468
+ function redactCredentialUrl(value) {
4469
+ return String(value).replace(/:\/\/[^/@]+@/g, "://[redacted]@");
4470
+ }
4471
+
4472
+ function redactText(value, maxLength = 1000) {
4473
+ return String(value ?? "")
4474
+ .replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g, "[redacted-private-key]")
4475
+ .replace(/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs])_[A-Za-z0-9_=-]{12,}\b/g, "[redacted-token]")
4476
+ .replace(/\b[A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@/g, "[redacted-credentials]@")
4477
+ .replace(/\b(?:authorization|x-api-key|api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^'\"\s]+/gi, "$1=[redacted]")
4478
+ .replace(/(OPENAI_API_KEY|FIREWORKS_API_KEY|ANTHROPIC_API_KEY|DATABASE_URL|REDIS_URL)=\S+/g, "$1=[redacted]")
4479
+ .replace(new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "~")
4480
+ .slice(0, maxLength);
4481
+ }
4482
+
4483
+ function unescapeJsonString(value) {
4484
+ try {
4485
+ return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);
4486
+ } catch {
4487
+ return value;
4488
+ }
4489
+ }
4490
+
4491
+ function hashString(value) {
4492
+ return createHash("sha256").update(String(value ?? "")).digest("hex");
4493
+ }
4494
+
4495
+ function hashObject(value) {
4496
+ return hashString(JSON.stringify(value));
4497
+ }
4498
+
4499
+ function readCodingSessionsState(path) {
4500
+ const value = readJsonOptional(path);
4501
+ return value && typeof value === "object" && !Array.isArray(value)
4502
+ ? { hashes: value.hashes && typeof value.hashes === "object" ? value.hashes : {} }
4503
+ : { hashes: {} };
4504
+ }
4505
+
4506
+ function codingSessionsStateFile(userId) {
4507
+ const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-state.json`);
4508
+ mkdirSync(dirname(path), { recursive: true });
4509
+ return path;
4510
+ }
4511
+
4512
+ function codingSessionsStatusFile(userId) {
4513
+ const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-status.json`);
4514
+ mkdirSync(dirname(path), { recursive: true });
4515
+ return path;
4516
+ }
4517
+
4518
+ async function latestCodingSessionsConfigPath() {
4519
+ const dir = join(homedir(), ".shepherd", "coding-sessions");
4520
+ try {
4521
+ const entries = await readdir(dir, { withFileTypes: true });
4522
+ const files = [];
4523
+ for (const entry of entries) {
4524
+ if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.includes("-state") || entry.name.includes("-status") || entry.name.includes("-queue")) continue;
4525
+ const path = join(dir, entry.name);
4526
+ const info = await stat(path).catch(() => null);
4527
+ if (info) files.push({ path, mtimeMs: info.mtimeMs });
4528
+ }
4529
+ return files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path ?? null;
4530
+ } catch {
4531
+ return null;
4532
+ }
4533
+ }
4534
+
4535
+ async function productionOnboardingStatusForCodingSessions(config) {
4536
+ if (!config?.userId || !config?.apiUrl) return null;
4537
+ const local = await readOptionalAgentState().catch(() => null);
4538
+ if (!local?.sessionToken || local.sessionId !== config.userId) return null;
4539
+ return getJson(
4540
+ `${trimTrailingSlash(config.apiUrl)}/onboarding/raw/session/${encodeURIComponent(config.userId)}/status`,
4541
+ { token: local.sessionToken },
4542
+ );
4543
+ }
4544
+
4545
+ function readJsonOptional(path) {
4546
+ try {
4547
+ return JSON.parse(readFileSync(path, "utf8"));
4548
+ } catch {
4549
+ return null;
4550
+ }
4551
+ }
4552
+
4553
+ class CodingSessionsBatchSender {
4554
+ constructor(apiUrl, agentToken, userId) {
4555
+ this.apiUrl = trimTrailingSlash(apiUrl);
4556
+ this.agentToken = agentToken;
4557
+ this.userId = userId;
4558
+ this.queueFile = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-queue.json`);
4559
+ }
4560
+
4561
+ async send(sessions) {
4562
+ const queued = this.loadQueue();
4563
+ const all = [...queued, ...sessions];
4564
+ if (!all.length) return { stored: 0, updated: 0, skipped: 0 };
4565
+
4566
+ let totalStored = 0;
4567
+ let totalUpdated = 0;
4568
+ let totalSkipped = 0;
4569
+
4570
+ for (let i = 0; i < all.length; i += MAX_BATCH_SIZE) {
4571
+ const batch = all.slice(i, i + MAX_BATCH_SIZE);
4572
+ try {
4573
+ const result = await this.postBatch(batch);
4574
+ totalStored += result.stored ?? 0;
4575
+ totalUpdated += result.updated ?? 0;
4576
+ totalSkipped += result.skipped ?? 0;
4577
+ } catch (err) {
4578
+ this.saveQueue(all.slice(i));
4579
+ console.error("Coding sessions batch send failed:", safeError(err));
4580
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
4581
+ }
4582
+ }
4583
+
4584
+ this.clearQueue();
4585
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
4586
+ }
4587
+
4588
+ async postBatch(sessions) {
4589
+ const res = await fetch(`${this.apiUrl}/api/coding-sessions/ingest`, {
4590
+ method: "POST",
4591
+ headers: {
4592
+ "Content-Type": "application/json",
4593
+ "x-api-key": this.agentToken,
4594
+ },
4595
+ body: JSON.stringify({ userId: this.userId, sessions }),
4596
+ });
4597
+
4598
+ const json = await res.json().catch(() => ({}));
4599
+ if (!res.ok) throw new Error(json.error ?? `Coding sessions ingest failed (${res.status})`);
4600
+ return json;
4601
+ }
4602
+
4603
+ loadQueue() {
4604
+ try {
4605
+ return JSON.parse(readFileSync(this.queueFile, "utf8"));
4606
+ } catch {
4607
+ return [];
4608
+ }
4609
+ }
4610
+
4611
+ saveQueue(sessions) {
4612
+ mkdirSync(dirname(this.queueFile), { recursive: true });
4613
+ writeFileSync(this.queueFile, JSON.stringify(sessions.slice(-MAX_QUEUE_MESSAGES)), { mode: 0o600 });
4614
+ }
4615
+
4616
+ clearQueue() {
4617
+ try {
4618
+ unlinkSync(this.queueFile);
4619
+ } catch {
4620
+ // Queue is already empty.
4621
+ }
4622
+ }
4623
+ }
4624
+
3302
4625
  class MessagesBatchSender {
3303
4626
  constructor(apiUrl, agentToken, userId) {
3304
4627
  this.apiUrl = trimTrailingSlash(apiUrl);
@@ -3347,6 +4670,21 @@ class MessagesBatchSender {
3347
4670
  return json;
3348
4671
  }
3349
4672
 
4673
+ async syncContacts(contacts) {
4674
+ const res = await fetch(`${this.apiUrl}/api/imessage/ingest/sync-contacts`, {
4675
+ method: "POST",
4676
+ headers: {
4677
+ "Content-Type": "application/json",
4678
+ "x-api-key": this.agentToken,
4679
+ },
4680
+ body: JSON.stringify({ userId: this.userId, contacts }),
4681
+ });
4682
+
4683
+ const json = await res.json().catch(() => ({}));
4684
+ if (!res.ok) throw new Error(json.error ?? `Messages contact sync failed (${res.status})`);
4685
+ return json;
4686
+ }
4687
+
3350
4688
  loadQueue() {
3351
4689
  try {
3352
4690
  return JSON.parse(readFileSync(this.queueFile, "utf8"));