askshepherd 0.1.32 → 0.1.37
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +79 -5
- package/bin/shepherd-onboard.js +1866 -108
- package/package.json +1 -1
package/bin/shepherd-onboard.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
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.
|
|
15
|
+
const PACKAGE_VERSION = "0.1.37";
|
|
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
|
|
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";
|
|
@@ -116,10 +127,16 @@ async function dispatch() {
|
|
|
116
127
|
await runMcpInstall();
|
|
117
128
|
} else if (command === "mcp") {
|
|
118
129
|
await runMcpProxy();
|
|
130
|
+
} else if (command === "status" || command === "sync-status" || command === "check") {
|
|
131
|
+
await runStatusCommand();
|
|
119
132
|
} else if (command === "messages-chats") {
|
|
120
133
|
await runMessagesChatsCommand();
|
|
121
134
|
} else if (command === "messages-agent") {
|
|
122
135
|
await runMessagesAgent();
|
|
136
|
+
} else if (command === "coding-sessions-agent") {
|
|
137
|
+
await runCodingSessionsAgent();
|
|
138
|
+
} else if (command === "coding-sessions-status") {
|
|
139
|
+
await runCodingSessionsStatus();
|
|
123
140
|
} else {
|
|
124
141
|
throw new Error(`Unknown command: ${command}`);
|
|
125
142
|
}
|
|
@@ -143,12 +160,7 @@ async function runOnboarding() {
|
|
|
143
160
|
const name = stringArg("name") ?? authenticatedName(workosLogin.authenticated) ?? await valueOrPrompt("name", "Full name");
|
|
144
161
|
const organizationName = await valueOrPrompt("org", "Organization name");
|
|
145
162
|
|
|
146
|
-
const sources =
|
|
147
|
-
google: !args["no-google"],
|
|
148
|
-
slack: !args["no-slack"],
|
|
149
|
-
granola: !args["no-granola"],
|
|
150
|
-
messages: !args["no-messages"],
|
|
151
|
-
};
|
|
163
|
+
const sources = selectedSources();
|
|
152
164
|
|
|
153
165
|
const session = await postJson(`${apiUrl}/onboarding/raw/session`, {
|
|
154
166
|
email,
|
|
@@ -237,6 +249,29 @@ async function runOnboarding() {
|
|
|
237
249
|
}
|
|
238
250
|
}
|
|
239
251
|
|
|
252
|
+
if (finalized.connected?.codingSessions?.agentToken) {
|
|
253
|
+
const configPath = await writeCodingSessionsConfig({
|
|
254
|
+
apiUrl,
|
|
255
|
+
userId: session.sessionId,
|
|
256
|
+
agentToken: finalized.connected.codingSessions.agentToken,
|
|
257
|
+
intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
if (!args["no-install-coding-sessions-agent"]) {
|
|
261
|
+
const install = await installCodingSessionsAgent(configPath, session.sessionId).catch((err) => ({
|
|
262
|
+
error: safeError(err),
|
|
263
|
+
}));
|
|
264
|
+
if ("error" in install) {
|
|
265
|
+
console.log(`\nLocal coding-session credentials saved: ${configPath}`);
|
|
266
|
+
console.log(`Coding-session background sync was not started: ${install.error}`);
|
|
267
|
+
} else {
|
|
268
|
+
console.log(`\nLocal coding-session sync started: ${install.label}`);
|
|
269
|
+
}
|
|
270
|
+
} else {
|
|
271
|
+
console.log(`\nLocal coding-session credentials saved: ${configPath}`);
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
240
275
|
const connected = Object.keys(finalized.connected ?? {});
|
|
241
276
|
console.log(`\nConnected sources: ${connected.length ? connected.join(", ") : "none"}`);
|
|
242
277
|
|
|
@@ -280,6 +315,10 @@ async function runAgentOnboarding() {
|
|
|
280
315
|
|| args["no-slack"]
|
|
281
316
|
|| args["no-granola"]
|
|
282
317
|
|| args["no-messages"]
|
|
318
|
+
|| args["no-coding-sessions"]
|
|
319
|
+
|| args["coding-sessions"]
|
|
320
|
+
|| stringArg("sources")
|
|
321
|
+
|| stringArg("add-sources")
|
|
283
322
|
);
|
|
284
323
|
|
|
285
324
|
if (!wantsStart) {
|
|
@@ -339,7 +378,7 @@ async function runAgentOnboarding() {
|
|
|
339
378
|
currentAction,
|
|
340
379
|
statePath,
|
|
341
380
|
messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
|
|
342
|
-
nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
381
|
+
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
382
|
needsUserAction: agentNeedsUserAction(sources, currentAction),
|
|
344
383
|
}, null, 2));
|
|
345
384
|
return;
|
|
@@ -358,7 +397,7 @@ async function runAgentOnboarding() {
|
|
|
358
397
|
});
|
|
359
398
|
|
|
360
399
|
console.log("\nAfter that modality is complete, run:");
|
|
361
|
-
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
400
|
+
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
401
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
363
402
|
}
|
|
364
403
|
|
|
@@ -683,39 +722,169 @@ async function runMcpProxy() {
|
|
|
683
722
|
{ name: "askshepherd-mcp-proxy", version: PACKAGE_VERSION },
|
|
684
723
|
{ capabilities: {} },
|
|
685
724
|
);
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
725
|
+
let remoteConnected = false;
|
|
726
|
+
let remoteConnectError = null;
|
|
727
|
+
try {
|
|
728
|
+
await remote.connect(new StreamableHTTPClientTransport(new URL(mcpUrl), {
|
|
729
|
+
requestInit: {
|
|
730
|
+
headers: {
|
|
731
|
+
Authorization: `Bearer ${token}`,
|
|
732
|
+
},
|
|
690
733
|
},
|
|
691
|
-
}
|
|
692
|
-
|
|
734
|
+
}));
|
|
735
|
+
remoteConnected = true;
|
|
736
|
+
} catch (err) {
|
|
737
|
+
remoteConnectError = safeError(err);
|
|
738
|
+
}
|
|
693
739
|
|
|
694
740
|
const passthroughResultSchema = typeof ResultSchema.passthrough === "function"
|
|
695
741
|
? ResultSchema.passthrough()
|
|
696
742
|
: ResultSchema;
|
|
743
|
+
const remoteCapabilities = remoteConnected ? remote.getServerCapabilities() ?? {} : {};
|
|
744
|
+
const remoteInstructions = remoteConnected ? remote.getInstructions() ?? "" : "";
|
|
745
|
+
const localTools = localMcpTools();
|
|
746
|
+
const localToolNames = new Set(localTools.map((tool) => tool.name));
|
|
697
747
|
const local = new Server(
|
|
698
748
|
{ name: "askshepherd", version: PACKAGE_VERSION },
|
|
699
749
|
{
|
|
700
|
-
capabilities:
|
|
701
|
-
instructions:
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
750
|
+
capabilities: { ...remoteCapabilities, tools: { listChanged: false } },
|
|
751
|
+
instructions: localMcpInstructions(remoteInstructions, remoteConnectError),
|
|
752
|
+
...(remoteConnected
|
|
753
|
+
? {
|
|
754
|
+
fallbackRequestHandler: async (request, extra) => remote.request(
|
|
755
|
+
request,
|
|
756
|
+
passthroughResultSchema,
|
|
757
|
+
{ ...proxyRequestOptions, signal: extra.signal },
|
|
758
|
+
),
|
|
759
|
+
fallbackNotificationHandler: async (notification) => {
|
|
760
|
+
await remote.notification(notification);
|
|
761
|
+
},
|
|
762
|
+
}
|
|
763
|
+
: {}),
|
|
710
764
|
},
|
|
711
765
|
);
|
|
712
|
-
local.setRequestHandler(ListToolsRequestSchema, async (request, extra) =>
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
766
|
+
local.setRequestHandler(ListToolsRequestSchema, async (request, extra) => {
|
|
767
|
+
if (!remoteConnected) return { tools: localTools };
|
|
768
|
+
const remoteTools = await remote
|
|
769
|
+
.listTools(request.params, { ...proxyRequestOptions, signal: extra.signal })
|
|
770
|
+
.catch(() => ({ tools: [] }));
|
|
771
|
+
return {
|
|
772
|
+
...remoteTools,
|
|
773
|
+
tools: [
|
|
774
|
+
...localTools,
|
|
775
|
+
...(remoteTools.tools ?? []).filter((tool) => !localToolNames.has(tool.name)),
|
|
776
|
+
],
|
|
777
|
+
};
|
|
778
|
+
});
|
|
779
|
+
local.setRequestHandler(CallToolRequestSchema, async (request, extra) => {
|
|
780
|
+
if (localToolNames.has(request.params.name)) {
|
|
781
|
+
return callLocalMcpTool(request.params.name);
|
|
782
|
+
}
|
|
783
|
+
if (!remoteConnected) {
|
|
784
|
+
return localMcpTextResult(`Production Shepherd MCP is unavailable (${remoteConnectError ?? "not connected"}). For local setup/sync status, use shepherd_status or run ${agentCommand()} status.`, true);
|
|
785
|
+
}
|
|
786
|
+
return remote.callTool(request.params, passthroughResultSchema, { ...proxyRequestOptions, signal: extra.signal });
|
|
787
|
+
});
|
|
716
788
|
await local.connect(new StdioServerTransport());
|
|
717
789
|
}
|
|
718
790
|
|
|
791
|
+
function localMcpTools() {
|
|
792
|
+
const emptyInputSchema = {
|
|
793
|
+
type: "object",
|
|
794
|
+
properties: {},
|
|
795
|
+
additionalProperties: false,
|
|
796
|
+
};
|
|
797
|
+
const readOnlyAnnotations = {
|
|
798
|
+
readOnlyHint: true,
|
|
799
|
+
destructiveHint: false,
|
|
800
|
+
openWorldHint: false,
|
|
801
|
+
};
|
|
802
|
+
|
|
803
|
+
return [
|
|
804
|
+
{
|
|
805
|
+
name: "shepherd_status",
|
|
806
|
+
description: "LOCAL Shepherd setup and sync status. Use this first when the user asks what they have enabled, what is connected, whether Shepherd is syncing, or why local Messages/Coding Sessions are not running. This is backed by the local askshepherd npm CLI; do not use production memory/wiki tools for local setup status.",
|
|
807
|
+
inputSchema: emptyInputSchema,
|
|
808
|
+
annotations: readOnlyAnnotations,
|
|
809
|
+
_meta: { provider: "local_npm", command: `${agentCommand()} status` },
|
|
810
|
+
},
|
|
811
|
+
{
|
|
812
|
+
name: "shepherd_local_status",
|
|
813
|
+
description: "Explicit local alias for shepherd_status. Returns the authoritative local askshepherd npm status path and current local setup/sync state.",
|
|
814
|
+
inputSchema: emptyInputSchema,
|
|
815
|
+
annotations: readOnlyAnnotations,
|
|
816
|
+
_meta: { provider: "local_npm", command: `${agentCommand()} status` },
|
|
817
|
+
},
|
|
818
|
+
{
|
|
819
|
+
name: "shepherd_setup_coding_sessions",
|
|
820
|
+
description: "LOCAL setup guide for Codex and Claude Code coding-session sync. Use when the user asks to set up coding agent sessions. Ask for consent, then guide the local askshepherd npm login/add-sources/continue/status commands.",
|
|
821
|
+
inputSchema: emptyInputSchema,
|
|
822
|
+
annotations: readOnlyAnnotations,
|
|
823
|
+
_meta: { provider: "local_npm", command: `${agentCommand()} agent --add-sources coding-sessions` },
|
|
824
|
+
},
|
|
825
|
+
];
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
function localMcpInstructions(remoteInstructions, remoteConnectError) {
|
|
829
|
+
return [
|
|
830
|
+
"This MCP server is the local askshepherd npm wrapper plus production Shepherd memory/wiki tools.",
|
|
831
|
+
`For local setup/sync questions like "what do I have set up on Shepherd", "what have I enabled", "is Shepherd syncing", "help me set up coding agent sessions", or "enable coding sessions", use shepherd_status or shepherd_setup_coding_sessions first. These local tools route to the local askshepherd npm status/setup flow and can inspect ~/.shepherd, LaunchAgents, and local Codex/Claude paths.`,
|
|
832
|
+
`If the user asks for raw local status outside MCP, tell them to run ${agentCommand()} status. For setup of coding agent sessions, ask consent, then use ${agentCommand()} agent --login if needed, ${agentCommand()} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>", ${agentCommand()} agent --continue, then ${agentCommand()} status.`,
|
|
833
|
+
"Use production memory/wiki tools only for company-memory questions, source recall, wiki lookup, messages/meetings retrieval, or coding-session work history that has already synced to Shepherd.",
|
|
834
|
+
"Important override: any production instruction saying not to use local shell commands applies only to production memory/wiki answers. It does not apply to local Shepherd setup, source enablement, or sync health.",
|
|
835
|
+
remoteConnectError ? `Production Shepherd MCP connection failed at startup: ${remoteConnectError}. Local setup/status tools are still available.` : "",
|
|
836
|
+
remoteInstructions ? `Production memory/wiki instructions: ${remoteInstructions}` : "",
|
|
837
|
+
].filter(Boolean).join(" ");
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async function callLocalMcpTool(name) {
|
|
841
|
+
if (name === "shepherd_status" || name === "shepherd_local_status") {
|
|
842
|
+
const status = await collectShepherdStatus();
|
|
843
|
+
return localMcpTextResult([
|
|
844
|
+
`Authoritative local status path: ${agentCommand()} status`,
|
|
845
|
+
"Use this result for setup/source/sync-health questions. Do not use production memory/wiki tools to answer what is enabled locally.",
|
|
846
|
+
renderShepherdStatus(status),
|
|
847
|
+
].join("\n\n"));
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
if (name === "shepherd_setup_coding_sessions") {
|
|
851
|
+
const status = await collectShepherdStatus();
|
|
852
|
+
return localMcpTextResult(renderCodingSessionsSetupMcpResult(status));
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
return localMcpTextResult(`Unknown local Shepherd MCP tool: ${name}`, true);
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
function renderCodingSessionsSetupMcpResult(status) {
|
|
859
|
+
const command = status.commands.addCodingSessions;
|
|
860
|
+
const alreadyConfigured = Boolean(status.local.codingSessions.configPath);
|
|
861
|
+
return [
|
|
862
|
+
"Local Shepherd coding-session setup",
|
|
863
|
+
"",
|
|
864
|
+
"Use this when the user asks to set up coding agent sessions. Ask for explicit consent before enabling this source: Shepherd will read local Codex and Claude Code session logs, redact sensitive strings locally, and sync bounded summaries plus repo/command/file metadata, not full raw transcripts.",
|
|
865
|
+
"",
|
|
866
|
+
alreadyConfigured
|
|
867
|
+
? "Current state: Coding Sessions already has a local config. Check whether the LaunchAgent is running and whether the last sync is healthy below."
|
|
868
|
+
: "Current state: Coding Sessions is not configured locally yet.",
|
|
869
|
+
"",
|
|
870
|
+
"Commands to run locally:",
|
|
871
|
+
`1. If there is no saved Shepherd login, run: ${status.commands.login}`,
|
|
872
|
+
`2. Add only this source: ${command}`,
|
|
873
|
+
`3. Finish/install the local agent: ${status.commands.continueSetup}`,
|
|
874
|
+
`4. Verify: ${status.commands.checkStatus}`,
|
|
875
|
+
"",
|
|
876
|
+
"Current local status:",
|
|
877
|
+
renderLocalCodingSessionsStatus(status.local.codingSessions).join("\n"),
|
|
878
|
+
].join("\n");
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
function localMcpTextResult(text, isError = false) {
|
|
882
|
+
return {
|
|
883
|
+
content: [{ type: "text", text }],
|
|
884
|
+
isError,
|
|
885
|
+
};
|
|
886
|
+
}
|
|
887
|
+
|
|
719
888
|
async function pollMcpLogin(apiUrl, started) {
|
|
720
889
|
const intervalMs = Math.max(1000, Number(started.intervalSeconds ?? 5) * 1000);
|
|
721
890
|
const expiresAt = Date.parse(started.expiresAt ?? "") || Date.now() + 600_000;
|
|
@@ -869,7 +1038,7 @@ async function continueAgentOnboarding() {
|
|
|
869
1038
|
if (granolaApiKey) body.granolaApiKey = granolaApiKey;
|
|
870
1039
|
if (messagesHandle) body.imessage = { handle: messagesHandle };
|
|
871
1040
|
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>".`);
|
|
1041
|
+
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
1042
|
}
|
|
874
1043
|
|
|
875
1044
|
const finalized = await postJson(
|
|
@@ -880,12 +1049,19 @@ async function continueAgentOnboarding() {
|
|
|
880
1049
|
state = await updateAgentStateFromOnboardingResponse(state, finalized);
|
|
881
1050
|
|
|
882
1051
|
if (finalized.connected?.messages?.agentToken) {
|
|
1052
|
+
const selectedChats = selectedChatIdsIncludeAll(selectedMessageChatIds)
|
|
1053
|
+
? [allMessagesChatsSelection()]
|
|
1054
|
+
: await loadSelectedMessageChatsForConfig(selectedMessageChatIds).catch((err) => {
|
|
1055
|
+
console.warn(`Could not load selected Messages chat metadata for contact hydration: ${safeError(err)}`);
|
|
1056
|
+
return [];
|
|
1057
|
+
});
|
|
883
1058
|
const configPath = await writeMessagesConfig({
|
|
884
1059
|
apiUrl: state.apiUrl,
|
|
885
1060
|
userId: state.sessionId,
|
|
886
1061
|
agentToken: finalized.connected.messages.agentToken,
|
|
887
1062
|
backfillDays: parseBackfillDays(args["messages-backfill-days"], null),
|
|
888
1063
|
allowedChatIds: selectedMessageChatIds,
|
|
1064
|
+
selectedChats,
|
|
889
1065
|
});
|
|
890
1066
|
|
|
891
1067
|
if (!args["no-install-messages-agent"]) {
|
|
@@ -902,6 +1078,29 @@ async function continueAgentOnboarding() {
|
|
|
902
1078
|
}
|
|
903
1079
|
}
|
|
904
1080
|
|
|
1081
|
+
if (finalized.connected?.codingSessions?.agentToken) {
|
|
1082
|
+
const configPath = await writeCodingSessionsConfig({
|
|
1083
|
+
apiUrl: state.apiUrl,
|
|
1084
|
+
userId: state.sessionId,
|
|
1085
|
+
agentToken: finalized.connected.codingSessions.agentToken,
|
|
1086
|
+
intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
|
|
1087
|
+
});
|
|
1088
|
+
|
|
1089
|
+
if (!args["no-install-coding-sessions-agent"]) {
|
|
1090
|
+
const install = await installCodingSessionsAgent(configPath, state.sessionId).catch((err) => ({
|
|
1091
|
+
error: safeError(err),
|
|
1092
|
+
}));
|
|
1093
|
+
if ("error" in install) {
|
|
1094
|
+
console.log(`Coding-session credentials saved: ${configPath}`);
|
|
1095
|
+
console.log(`Coding-session background sync was not started: ${install.error}`);
|
|
1096
|
+
} else {
|
|
1097
|
+
console.log(`Coding-session background sync started: ${install.label}`);
|
|
1098
|
+
}
|
|
1099
|
+
} else {
|
|
1100
|
+
console.log(`Coding-session credentials saved: ${configPath}`);
|
|
1101
|
+
}
|
|
1102
|
+
}
|
|
1103
|
+
|
|
905
1104
|
const errors = finalized.errors && Object.keys(finalized.errors).length ? finalized.errors : null;
|
|
906
1105
|
const currentAction = errors
|
|
907
1106
|
? await openNextAgentModality({
|
|
@@ -915,11 +1114,12 @@ async function continueAgentOnboarding() {
|
|
|
915
1114
|
console.log(JSON.stringify({
|
|
916
1115
|
status: errors ? "waiting" : "completed",
|
|
917
1116
|
connected: Object.keys(finalized.connected ?? {}),
|
|
1117
|
+
alreadyConnected: Object.keys(finalized.alreadyConnected ?? {}),
|
|
918
1118
|
processingEnabled: finalized.processingEnabled === true,
|
|
919
1119
|
processing: finalized.processing,
|
|
920
1120
|
errors: errors ? safeErrorRecord(errors) : undefined,
|
|
921
1121
|
currentAction,
|
|
922
|
-
nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
1122
|
+
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
1123
|
mcpInstall: errors ? undefined : {
|
|
924
1124
|
prompt: "Ask where to install Shepherd MCP for this customer: Codex, Claude Code, Cursor, any subset, or none.",
|
|
925
1125
|
targets: MCP_INSTALL_TARGETS,
|
|
@@ -940,7 +1140,7 @@ async function continueAgentOnboarding() {
|
|
|
940
1140
|
});
|
|
941
1141
|
|
|
942
1142
|
console.log("\nAfter that modality is complete, rerun:");
|
|
943
|
-
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
1143
|
+
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
1144
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
945
1145
|
return;
|
|
946
1146
|
}
|
|
@@ -953,20 +1153,186 @@ async function continueAgentOnboarding() {
|
|
|
953
1153
|
}
|
|
954
1154
|
|
|
955
1155
|
async function printAgentStatus() {
|
|
956
|
-
|
|
957
|
-
|
|
958
|
-
|
|
959
|
-
|
|
960
|
-
);
|
|
961
|
-
|
|
962
|
-
|
|
963
|
-
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
1156
|
+
await runStatusCommand();
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
async function runStatusCommand() {
|
|
1160
|
+
const status = await collectShepherdStatus();
|
|
1161
|
+
if (args.json) {
|
|
1162
|
+
console.log(JSON.stringify(status, null, 2));
|
|
1163
|
+
return;
|
|
1164
|
+
}
|
|
1165
|
+
printShepherdStatus(status);
|
|
1166
|
+
}
|
|
1167
|
+
|
|
1168
|
+
async function collectShepherdStatus() {
|
|
1169
|
+
const statePath = agentStatePath();
|
|
1170
|
+
const state = await readOptionalAgentState();
|
|
1171
|
+
let production = null;
|
|
1172
|
+
let productionError = null;
|
|
1173
|
+
|
|
1174
|
+
if (state?.apiUrl && state?.sessionId && state?.sessionToken) {
|
|
1175
|
+
try {
|
|
1176
|
+
production = await getJson(
|
|
1177
|
+
`${trimTrailingSlash(state.apiUrl)}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/status`,
|
|
1178
|
+
{ token: state.sessionToken },
|
|
1179
|
+
);
|
|
1180
|
+
await updateAgentStateFromOnboardingResponse(state, production);
|
|
1181
|
+
} catch (err) {
|
|
1182
|
+
productionError = safeError(err);
|
|
1183
|
+
}
|
|
1184
|
+
}
|
|
1185
|
+
|
|
1186
|
+
const userId = production?.sessionId ?? state?.sessionId ?? null;
|
|
1187
|
+
const providers = production?.providers ?? state?.providers ?? {};
|
|
1188
|
+
const messagesLocal = await collectMessagesLocalStatus(userId);
|
|
1189
|
+
const codingSessionsLocal = await collectCodingSessionsLocalStatus(userId);
|
|
1190
|
+
|
|
1191
|
+
return {
|
|
1192
|
+
statePath,
|
|
1193
|
+
configured: Boolean(state),
|
|
1194
|
+
account: production?.account ?? state?.account ?? null,
|
|
1195
|
+
savedSources: state?.sources ?? {},
|
|
1196
|
+
providers,
|
|
1197
|
+
production: production
|
|
1198
|
+
? {
|
|
1199
|
+
status: production.status,
|
|
1200
|
+
providers,
|
|
1201
|
+
rawOnly: production.rawOnly === true,
|
|
1202
|
+
processingEnabled: production.processingEnabled === true,
|
|
1203
|
+
processing: production.processing,
|
|
1204
|
+
}
|
|
1205
|
+
: null,
|
|
1206
|
+
productionError,
|
|
1207
|
+
local: {
|
|
1208
|
+
messages: messagesLocal,
|
|
1209
|
+
codingSessions: codingSessionsLocal,
|
|
1210
|
+
},
|
|
1211
|
+
commands: {
|
|
1212
|
+
login: `${agentCommand()} agent --login`,
|
|
1213
|
+
checkStatus: `${agentCommand()} status`,
|
|
1214
|
+
addCodingSessions: `${agentCommand()} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"`,
|
|
1215
|
+
continueSetup: `${agentCommand()} agent --continue`,
|
|
1216
|
+
codingSessionsStatus: `${agentCommand()} coding-sessions-status`,
|
|
1217
|
+
messagesChats: `${agentCommand()} messages-chats`,
|
|
1218
|
+
},
|
|
1219
|
+
};
|
|
1220
|
+
}
|
|
1221
|
+
|
|
1222
|
+
function printShepherdStatus(status) {
|
|
1223
|
+
console.log(renderShepherdStatus(status));
|
|
1224
|
+
}
|
|
1225
|
+
|
|
1226
|
+
function renderShepherdStatus(status) {
|
|
1227
|
+
const lines = ["", "Shepherd sync status", ""];
|
|
1228
|
+
|
|
1229
|
+
if (status.account) {
|
|
1230
|
+
const email = status.account.email ? ` <${status.account.email}>` : "";
|
|
1231
|
+
const org = status.account.organizationName ? ` / ${status.account.organizationName}` : "";
|
|
1232
|
+
lines.push(`Account: ${status.account.name ?? "unknown"}${email}${org}`);
|
|
1233
|
+
} else {
|
|
1234
|
+
lines.push("Account: no saved Shepherd onboarding session found");
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
if (status.productionError) {
|
|
1238
|
+
lines.push(`Production status: unavailable (${status.productionError})`);
|
|
1239
|
+
} else if (status.production) {
|
|
1240
|
+
lines.push(`Production status: ${status.production.status ?? "unknown"}`);
|
|
1241
|
+
lines.push(`Downstream processing: ${status.production.processingEnabled ? "enabled" : "not enabled"}`);
|
|
1242
|
+
} else {
|
|
1243
|
+
lines.push("Production status: not checked");
|
|
1244
|
+
}
|
|
1245
|
+
|
|
1246
|
+
lines.push("", "Sources:");
|
|
1247
|
+
for (const source of statusSourceRows(status.providers, status.savedSources)) {
|
|
1248
|
+
const label = source.connected
|
|
1249
|
+
? "connected"
|
|
1250
|
+
: source.seen
|
|
1251
|
+
? "not connected"
|
|
1252
|
+
: source.selected
|
|
1253
|
+
? "selected in saved setup; connection unknown"
|
|
1254
|
+
: "not enabled in saved setup";
|
|
1255
|
+
lines.push(`- ${source.label}: ${label}`);
|
|
1256
|
+
}
|
|
1257
|
+
|
|
1258
|
+
lines.push("", "Local sync:");
|
|
1259
|
+
lines.push(...renderLocalMessagesStatus(status.local.messages));
|
|
1260
|
+
lines.push(...renderLocalCodingSessionsStatus(status.local.codingSessions));
|
|
1261
|
+
|
|
1262
|
+
lines.push("", "Useful commands:");
|
|
1263
|
+
if (!status.configured) lines.push(`- Sign in: ${status.commands.login}`);
|
|
1264
|
+
lines.push(`- Check again: ${status.commands.checkStatus}`);
|
|
1265
|
+
lines.push(`- Add coding sessions: ${status.commands.addCodingSessions}`);
|
|
1266
|
+
lines.push(`- Continue pending setup: ${status.commands.continueSetup}`);
|
|
1267
|
+
return lines.join("\n");
|
|
1268
|
+
}
|
|
1269
|
+
|
|
1270
|
+
function statusSourceRows(providers, savedSources = {}) {
|
|
1271
|
+
const definitions = [
|
|
1272
|
+
["google", "Google Workspace", "google"],
|
|
1273
|
+
["slack", "Slack", "slack"],
|
|
1274
|
+
["granola", "Granola", "granola"],
|
|
1275
|
+
["messages", "Messages", "messages"],
|
|
1276
|
+
["codingSessions", "Coding Sessions", "codingSessions"],
|
|
1277
|
+
];
|
|
1278
|
+
return definitions.map(([key, label, sourceKey]) => ({
|
|
1279
|
+
key,
|
|
1280
|
+
label,
|
|
1281
|
+
seen: Boolean(providers?.[key]),
|
|
1282
|
+
selected: savedSources?.[sourceKey] === true,
|
|
1283
|
+
connected: providers?.[key]?.connected === true,
|
|
1284
|
+
}));
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
function printLocalMessagesStatus(status) {
|
|
1288
|
+
console.log(renderLocalMessagesStatus(status).join("\n"));
|
|
1289
|
+
}
|
|
1290
|
+
|
|
1291
|
+
function renderLocalMessagesStatus(status) {
|
|
1292
|
+
const lines = [];
|
|
1293
|
+
const prefix = "- Messages local agent";
|
|
1294
|
+
if (!status.configPath) {
|
|
1295
|
+
lines.push(`${prefix}: not configured`);
|
|
1296
|
+
} else {
|
|
1297
|
+
lines.push(`${prefix}: configured at ${status.configPath}`);
|
|
1298
|
+
}
|
|
1299
|
+
if (status.launch) {
|
|
1300
|
+
lines.push(` LaunchAgent: ${status.launch.label} ${status.launch.running ? "running" : "not running or unknown"}`);
|
|
1301
|
+
} else {
|
|
1302
|
+
lines.push(" LaunchAgent: not installed or unavailable");
|
|
1303
|
+
}
|
|
1304
|
+
lines.push(` Messages database: ${status.storage.readable ? "readable" : `not readable (${status.storage.reason})`}`);
|
|
1305
|
+
lines.push(` Queued unsent messages: ${status.queueDepth}`);
|
|
1306
|
+
return lines;
|
|
1307
|
+
}
|
|
1308
|
+
|
|
1309
|
+
function printLocalCodingSessionsStatus(status) {
|
|
1310
|
+
console.log(renderLocalCodingSessionsStatus(status).join("\n"));
|
|
1311
|
+
}
|
|
1312
|
+
|
|
1313
|
+
function renderLocalCodingSessionsStatus(status) {
|
|
1314
|
+
const lines = [];
|
|
1315
|
+
const prefix = "- Coding Sessions local agent";
|
|
1316
|
+
if (!status.configPath) {
|
|
1317
|
+
lines.push(`${prefix}: not configured`);
|
|
1318
|
+
} else {
|
|
1319
|
+
lines.push(`${prefix}: configured at ${status.configPath}`);
|
|
1320
|
+
}
|
|
1321
|
+
if (status.launch) {
|
|
1322
|
+
lines.push(` LaunchAgent: ${status.launch.label} ${status.launch.running ? "running" : "not running or unknown"}`);
|
|
1323
|
+
} else {
|
|
1324
|
+
lines.push(" LaunchAgent: not installed or unavailable");
|
|
1325
|
+
}
|
|
1326
|
+
for (const probe of status.localFolders) {
|
|
1327
|
+
lines.push(` ${probe.provider}: ${probe.path} ${probe.readable ? "readable" : `not readable (${probe.reason})`}`);
|
|
1328
|
+
}
|
|
1329
|
+
if (status.lastSync) {
|
|
1330
|
+
lines.push(` Last sync: ${status.lastSync.finishedAt ?? "unknown"} (${status.lastSync.scanned ?? 0} scanned, ${status.lastSync.changed ?? 0} changed)`);
|
|
1331
|
+
} else {
|
|
1332
|
+
lines.push(" Last sync: none recorded");
|
|
1333
|
+
}
|
|
1334
|
+
lines.push(` Queued unsent sessions: ${status.queueDepth}`);
|
|
1335
|
+
return lines;
|
|
970
1336
|
}
|
|
971
1337
|
|
|
972
1338
|
async function runMessagesChatsCommand() {
|
|
@@ -982,8 +1348,10 @@ async function runMessagesChatsCommand() {
|
|
|
982
1348
|
|
|
983
1349
|
if (!args.text && !args.list) {
|
|
984
1350
|
const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
|
|
985
|
-
const selectedIds = selected.map((chat) => chat.chatId).join(",");
|
|
986
|
-
console.log(
|
|
1351
|
+
const selectedIds = selectedIncludesAllChats(selected) ? "all" : selected.map((chat) => chat.chatId).join(",");
|
|
1352
|
+
console.log(selectedIncludesAllChats(selected)
|
|
1353
|
+
? "\nSelected all current and future Messages chats."
|
|
1354
|
+
: `\nSelected ${selected.length} Messages chat(s).`);
|
|
987
1355
|
console.log(`messages-chat-ids=${selectedIds}`);
|
|
988
1356
|
console.log("\nContinue with:");
|
|
989
1357
|
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
|
|
@@ -997,6 +1365,7 @@ async function runMessagesChatsCommand() {
|
|
|
997
1365
|
}
|
|
998
1366
|
console.log("\nPass selected IDs to:");
|
|
999
1367
|
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
|
|
1368
|
+
console.log(` Or explicitly sync all current and future chats with: --messages-chat-ids all`);
|
|
1000
1369
|
}
|
|
1001
1370
|
|
|
1002
1371
|
async function runMessagesAgent() {
|
|
@@ -1007,37 +1376,187 @@ async function runMessagesAgent() {
|
|
|
1007
1376
|
const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
|
|
1008
1377
|
const userId = requiredConfigString(config.userId, "userId");
|
|
1009
1378
|
const agentToken = requiredConfigString(config.agentToken, "agentToken");
|
|
1379
|
+
mergeShepherdOwnedMessageHandles(config.excludedMessageHandles);
|
|
1010
1380
|
const backfillDays = parseBackfillDays(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays, null);
|
|
1011
1381
|
const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
|
|
1012
|
-
|
|
1013
|
-
|
|
1382
|
+
const allChats = config.allChats === true || selectedChatIdsIncludeAll(allowedChatIds);
|
|
1383
|
+
if (!allChats && allowedChatIds.length === 0) {
|
|
1384
|
+
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
1385
|
}
|
|
1015
1386
|
|
|
1016
1387
|
const kit = await import("@photon-ai/imessage-kit");
|
|
1017
1388
|
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
1018
1389
|
const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
|
|
1019
|
-
const contactLookup = buildContactLookup();
|
|
1390
|
+
const contactLookup = createMutableContactLookup(buildContactLookup());
|
|
1020
1391
|
const serializer = createMessageSerializer(kit, contactLookup);
|
|
1392
|
+
const contactSync = startMessagesContactSync(sender, contactLookup, {
|
|
1393
|
+
syncAllContacts: allChats,
|
|
1394
|
+
seedHandles: allChats ? [] : selectedChatContactSeedHandles(config.selectedChats, allowedChatIds),
|
|
1395
|
+
});
|
|
1021
1396
|
|
|
1022
1397
|
console.log("Shepherd Messages raw sync starting");
|
|
1023
|
-
console.log(
|
|
1398
|
+
console.log(allChats
|
|
1399
|
+
? "Messages chat filter: all current and future chats"
|
|
1400
|
+
: `Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
|
|
1024
1401
|
|
|
1025
1402
|
try {
|
|
1403
|
+
await contactSync.syncNow({ forceAll: true, reason: "startup" }).catch((err) => {
|
|
1404
|
+
console.error("Initial Messages contact sync failed:", safeError(err));
|
|
1405
|
+
});
|
|
1026
1406
|
await loadGroupChatNames(sdk, serializer);
|
|
1027
1407
|
loadSelectedChatNames(config.selectedChats, serializer);
|
|
1028
1408
|
|
|
1029
1409
|
if (backfillDays !== 0) {
|
|
1030
|
-
await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
|
|
1410
|
+
await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync);
|
|
1411
|
+
await contactSync.syncNow({ forceAll: true, reason: "post-backfill" }).catch((err) => {
|
|
1412
|
+
console.error("Post-backfill Messages contact sync failed:", safeError(err));
|
|
1413
|
+
});
|
|
1031
1414
|
}
|
|
1032
1415
|
|
|
1033
|
-
await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
|
|
1034
|
-
await
|
|
1416
|
+
await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync);
|
|
1417
|
+
await contactSync.syncNow({ forceAll: true, reason: "post-gap-fill" }).catch((err) => {
|
|
1418
|
+
console.error("Post-gap-fill Messages contact sync failed:", safeError(err));
|
|
1419
|
+
});
|
|
1420
|
+
await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, { contactSync });
|
|
1035
1421
|
} catch (err) {
|
|
1422
|
+
contactSync.stop();
|
|
1036
1423
|
await sdk.close?.().catch(() => undefined);
|
|
1037
1424
|
throw err;
|
|
1038
1425
|
}
|
|
1039
1426
|
}
|
|
1040
1427
|
|
|
1428
|
+
async function runCodingSessionsAgent() {
|
|
1429
|
+
const configPath = stringArg("config");
|
|
1430
|
+
if (!configPath) throw new Error("coding-sessions-agent requires --config <path>");
|
|
1431
|
+
|
|
1432
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
1433
|
+
const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
|
|
1434
|
+
const userId = requiredConfigString(config.userId, "userId");
|
|
1435
|
+
const agentToken = requiredConfigString(config.agentToken, "agentToken");
|
|
1436
|
+
const sender = new CodingSessionsBatchSender(apiUrl, agentToken, userId);
|
|
1437
|
+
const statePath = codingSessionsStateFile(userId);
|
|
1438
|
+
const statusPath = codingSessionsStatusFile(userId);
|
|
1439
|
+
const intervalSeconds = Math.max(15, Math.floor(Number(args["interval-seconds"] ?? config.intervalSeconds ?? 60)));
|
|
1440
|
+
|
|
1441
|
+
console.log("Shepherd Coding Sessions sync starting");
|
|
1442
|
+
while (true) {
|
|
1443
|
+
const startedAt = new Date().toISOString();
|
|
1444
|
+
const previous = readCodingSessionsState(statePath);
|
|
1445
|
+
const scan = await scanCodingSessions(config, previous);
|
|
1446
|
+
const changed = scan.sessions.filter((session) => previous.hashes[session.sourcePathHash] !== session.contentHash);
|
|
1447
|
+
const sendResult = changed.length > 0 ? await sender.send(changed) : { stored: 0, updated: 0, skipped: 0 };
|
|
1448
|
+
const nextState = {
|
|
1449
|
+
hashes: { ...previous.hashes },
|
|
1450
|
+
updatedAt: new Date().toISOString(),
|
|
1451
|
+
};
|
|
1452
|
+
for (const session of changed) {
|
|
1453
|
+
nextState.hashes[session.sourcePathHash] = session.contentHash;
|
|
1454
|
+
}
|
|
1455
|
+
writeFileSync(statePath, JSON.stringify(nextState, null, 2), { mode: 0o600 });
|
|
1456
|
+
const status = {
|
|
1457
|
+
ok: true,
|
|
1458
|
+
startedAt,
|
|
1459
|
+
finishedAt: new Date().toISOString(),
|
|
1460
|
+
scanned: scan.sessions.length,
|
|
1461
|
+
changed: changed.length,
|
|
1462
|
+
sent: sendResult,
|
|
1463
|
+
probes: scan.probes,
|
|
1464
|
+
errors: scan.errors,
|
|
1465
|
+
};
|
|
1466
|
+
writeFileSync(statusPath, JSON.stringify(status, null, 2), { mode: 0o600 });
|
|
1467
|
+
console.log(`Coding session scan: ${scan.sessions.length} scanned, ${changed.length} changed, ${sendResult.stored ?? 0} stored, ${sendResult.updated ?? 0} updated, ${sendResult.skipped ?? 0} skipped`);
|
|
1468
|
+
|
|
1469
|
+
if (args.once) return;
|
|
1470
|
+
await sleep(intervalSeconds * 1000);
|
|
1471
|
+
}
|
|
1472
|
+
}
|
|
1473
|
+
|
|
1474
|
+
async function collectMessagesLocalStatus(preferredUserId = null) {
|
|
1475
|
+
const configPath = await messagesConfigPathForUser(preferredUserId) ?? await latestMessagesConfigPath();
|
|
1476
|
+
const config = configPath ? readJsonOptional(configPath) : null;
|
|
1477
|
+
const userId = config?.userId ?? preferredUserId ?? null;
|
|
1478
|
+
const safeId = userId ? safeFileId(userId) : null;
|
|
1479
|
+
const label = safeId ? `ai.shepherd.raw-messages.${safeId}` : null;
|
|
1480
|
+
const queue = safeId ? readJsonOptional(join(homedir(), ".shepherd", "raw-messages", `${safeId}-queue.json`)) : null;
|
|
1481
|
+
|
|
1482
|
+
return {
|
|
1483
|
+
configPath: configPath ?? null,
|
|
1484
|
+
userId,
|
|
1485
|
+
allChats: config?.allChats === true,
|
|
1486
|
+
selectedChatCount: Array.isArray(config?.allowedChatIds) ? config.allowedChatIds.length : 0,
|
|
1487
|
+
storage: await probePath("messages", MESSAGES_CHAT_DB_PATH),
|
|
1488
|
+
launch: localLaunchStatus(label),
|
|
1489
|
+
queueDepth: Array.isArray(queue) ? queue.length : 0,
|
|
1490
|
+
};
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
async function collectCodingSessionsLocalStatus(preferredUserId = null, explicitConfigPath = null) {
|
|
1494
|
+
const configPath = explicitConfigPath ?? await codingSessionsConfigPathForUser(preferredUserId) ?? await latestCodingSessionsConfigPath();
|
|
1495
|
+
const config = configPath ? readJsonOptional(configPath) : null;
|
|
1496
|
+
const userId = config?.userId ?? preferredUserId ?? null;
|
|
1497
|
+
const safeId = userId ? safeFileId(userId) : null;
|
|
1498
|
+
const label = safeId ? `ai.shepherd.coding-sessions.${safeId}` : null;
|
|
1499
|
+
const queue = safeId ? readJsonOptional(join(homedir(), ".shepherd", "coding-sessions", `${safeId}-queue.json`)) : null;
|
|
1500
|
+
|
|
1501
|
+
return {
|
|
1502
|
+
configPath: configPath ?? null,
|
|
1503
|
+
userId,
|
|
1504
|
+
localFolders: await probeCodingSessionPaths(config ?? {}),
|
|
1505
|
+
launch: localLaunchStatus(label),
|
|
1506
|
+
lastSync: userId ? readJsonOptional(codingSessionsStatusFile(userId)) : null,
|
|
1507
|
+
queueDepth: Array.isArray(queue) ? queue.length : 0,
|
|
1508
|
+
};
|
|
1509
|
+
}
|
|
1510
|
+
|
|
1511
|
+
function localLaunchStatus(label) {
|
|
1512
|
+
if (!label || platform() !== "darwin") return null;
|
|
1513
|
+
const state = readLaunchctlPrint(`gui/${process.getuid?.() ?? 501}/${label}`);
|
|
1514
|
+
return {
|
|
1515
|
+
label,
|
|
1516
|
+
running: /state = running|job state = running/i.test(state),
|
|
1517
|
+
state,
|
|
1518
|
+
};
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
async function runCodingSessionsStatus() {
|
|
1522
|
+
const configPath = stringArg("config") ?? await latestCodingSessionsConfigPath();
|
|
1523
|
+
const status = await collectCodingSessionsLocalStatus(null, configPath);
|
|
1524
|
+
const config = configPath ? readJsonOptional(configPath) : null;
|
|
1525
|
+
const production = await productionOnboardingStatusForCodingSessions(config).catch((err) => ({ error: safeError(err) }));
|
|
1526
|
+
const detailedStatus = {
|
|
1527
|
+
...status,
|
|
1528
|
+
production,
|
|
1529
|
+
};
|
|
1530
|
+
|
|
1531
|
+
if (args.json) {
|
|
1532
|
+
console.log(JSON.stringify(detailedStatus, null, 2));
|
|
1533
|
+
return;
|
|
1534
|
+
}
|
|
1535
|
+
|
|
1536
|
+
console.log("\nShepherd coding-session sync status\n");
|
|
1537
|
+
console.log(`Config: ${detailedStatus.configPath ?? "not found"}`);
|
|
1538
|
+
if (detailedStatus.userId) console.log(`User: ${detailedStatus.userId}`);
|
|
1539
|
+
for (const probe of detailedStatus.localFolders) {
|
|
1540
|
+
console.log(`- ${probe.provider}: ${probe.path} ${probe.readable ? "readable" : `not readable (${probe.reason})`}`);
|
|
1541
|
+
}
|
|
1542
|
+
if (detailedStatus.launch) {
|
|
1543
|
+
console.log(`LaunchAgent: ${detailedStatus.launch.label} ${detailedStatus.launch.running ? "running" : "not running or unknown"}`);
|
|
1544
|
+
} else {
|
|
1545
|
+
console.log("LaunchAgent: not installed or unavailable");
|
|
1546
|
+
}
|
|
1547
|
+
if (detailedStatus.lastSync) {
|
|
1548
|
+
console.log(`Last sync: ${detailedStatus.lastSync.finishedAt ?? "unknown"} (${detailedStatus.lastSync.scanned ?? 0} scanned, ${detailedStatus.lastSync.changed ?? 0} changed)`);
|
|
1549
|
+
} else {
|
|
1550
|
+
console.log("Last sync: none recorded");
|
|
1551
|
+
}
|
|
1552
|
+
console.log(`Queued unsent sessions: ${detailedStatus.queueDepth}`);
|
|
1553
|
+
if (production?.providers?.codingSessions) {
|
|
1554
|
+
console.log(`Production provider: ${production.providers.codingSessions.connected ? "connected" : "not connected"}`);
|
|
1555
|
+
} else if (production?.error) {
|
|
1556
|
+
console.log(`Production provider: unavailable (${production.error})`);
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
|
|
1041
1560
|
function parseArgs(argv) {
|
|
1042
1561
|
const parsed = {};
|
|
1043
1562
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1070,8 +1589,11 @@ Usage:
|
|
|
1070
1589
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
1071
1590
|
npx -y ${PACKAGE_NAME}@latest agent --login
|
|
1072
1591
|
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>
|
|
1592
|
+
npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids|all>
|
|
1593
|
+
npx -y ${PACKAGE_NAME}@latest agent --add-sources coding-sessions --name <name> --org <organization>
|
|
1074
1594
|
npx -y ${PACKAGE_NAME}@latest agent --status
|
|
1595
|
+
npx -y ${PACKAGE_NAME}@latest status
|
|
1596
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1075
1597
|
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
1076
1598
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
1077
1599
|
|
|
@@ -1081,6 +1603,62 @@ The bare agent command is intended for coding-agent shells. For direct terminal
|
|
|
1081
1603
|
return;
|
|
1082
1604
|
}
|
|
1083
1605
|
|
|
1606
|
+
if (which === "status" || which === "sync-status" || which === "check") {
|
|
1607
|
+
console.log(`Shepherd sync status
|
|
1608
|
+
|
|
1609
|
+
Usage:
|
|
1610
|
+
npx -y ${PACKAGE_NAME}@latest status
|
|
1611
|
+
npx -y ${PACKAGE_NAME}@latest status --json
|
|
1612
|
+
|
|
1613
|
+
Shows the saved Shepherd account, connected cloud sources, production processing
|
|
1614
|
+
state, and local background sync health for Messages and Coding Sessions.
|
|
1615
|
+
|
|
1616
|
+
Aliases:
|
|
1617
|
+
sync-status
|
|
1618
|
+
check
|
|
1619
|
+
|
|
1620
|
+
Options:
|
|
1621
|
+
--json Print machine-readable status.
|
|
1622
|
+
--state <path> Local onboarding state file. Defaults to ~/.shepherd/raw-onboarding-agent.json.
|
|
1623
|
+
--help Show this help.
|
|
1624
|
+
`);
|
|
1625
|
+
return;
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
if (which === "coding-sessions-agent") {
|
|
1629
|
+
console.log(`Shepherd coding-session sync agent
|
|
1630
|
+
|
|
1631
|
+
Usage:
|
|
1632
|
+
shepherd-onboard coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json
|
|
1633
|
+
|
|
1634
|
+
Options:
|
|
1635
|
+
--config <path> Coding-session agent config created by onboarding.
|
|
1636
|
+
--once Run one scan and exit.
|
|
1637
|
+
--interval-seconds <n> Poll interval for live sync. Defaults to config or 60.
|
|
1638
|
+
--debug Print extra collector details.
|
|
1639
|
+
--help Show this help.
|
|
1640
|
+
`);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
if (which === "coding-sessions-status") {
|
|
1645
|
+
console.log(`Shepherd coding-session sync status
|
|
1646
|
+
|
|
1647
|
+
Usage:
|
|
1648
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1649
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status --json
|
|
1650
|
+
|
|
1651
|
+
Shows whether local Codex and Claude Code session folders are readable, whether
|
|
1652
|
+
the background LaunchAgent is installed/running, and the last local sync result.
|
|
1653
|
+
|
|
1654
|
+
Options:
|
|
1655
|
+
--config <path> Config path. Defaults to the latest file in ~/.shepherd/coding-sessions.
|
|
1656
|
+
--json Print machine-readable status.
|
|
1657
|
+
--help Show this help.
|
|
1658
|
+
`);
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
|
|
1084
1662
|
if (which === "messages-agent") {
|
|
1085
1663
|
console.log(`Shepherd Messages raw sync agent
|
|
1086
1664
|
|
|
@@ -1110,6 +1688,7 @@ macOS permission:
|
|
|
1110
1688
|
|
|
1111
1689
|
Options:
|
|
1112
1690
|
--limit <n> Number of recent chats to load for search. Defaults to ${DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT}.
|
|
1691
|
+
--messages-chat-ids all Supported on agent --continue to watch every current and future Messages chat.
|
|
1113
1692
|
--text Print a terminal list instead of opening the selector page.
|
|
1114
1693
|
--no-open Print the local selector URL instead of opening it.
|
|
1115
1694
|
--no-permission-prompt Print macOS permission instructions without waiting for confirmation.
|
|
@@ -1178,6 +1757,9 @@ Usage:
|
|
|
1178
1757
|
|
|
1179
1758
|
This command is installed into MCP clients. It reads ~/.shepherd/mcp.json and
|
|
1180
1759
|
proxies stdio MCP traffic to the authenticated production Shepherd MCP endpoint.
|
|
1760
|
+
It also exposes local npm-backed setup/status tools such as shepherd_status and
|
|
1761
|
+
shepherd_setup_coding_sessions so agents can answer what is enabled locally and
|
|
1762
|
+
guide coding-session setup.
|
|
1181
1763
|
|
|
1182
1764
|
Options:
|
|
1183
1765
|
--state <path> Token state file. Defaults to ~/.shepherd/mcp.json.
|
|
@@ -1192,9 +1774,11 @@ Options:
|
|
|
1192
1774
|
Usage:
|
|
1193
1775
|
npx -y ${PACKAGE_NAME}@latest
|
|
1194
1776
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
1777
|
+
npx -y ${PACKAGE_NAME}@latest status
|
|
1195
1778
|
npx -y ${PACKAGE_NAME}@latest mcp-login
|
|
1196
1779
|
npx -y ${PACKAGE_NAME}@latest mcp-install
|
|
1197
1780
|
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
1781
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1198
1782
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
1199
1783
|
|
|
1200
1784
|
Options:
|
|
@@ -1203,7 +1787,7 @@ Options:
|
|
|
1203
1787
|
--org <name> Organization name.
|
|
1204
1788
|
--granola-api-key <key> Granola API key.
|
|
1205
1789
|
--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.
|
|
1790
|
+
--messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats, or all to watch every current and future chat.
|
|
1207
1791
|
--messages-backfill-days <days>
|
|
1208
1792
|
Local Messages backfill window. Defaults to all selected chat history.
|
|
1209
1793
|
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
|
|
@@ -1211,8 +1795,13 @@ Options:
|
|
|
1211
1795
|
--no-granola Skip Granola.
|
|
1212
1796
|
--no-open-granola Do not open the Granola API key screen.
|
|
1213
1797
|
--no-messages Skip local Messages.
|
|
1798
|
+
--coding-sessions Opt in to local Codex/Claude Code session summary sync.
|
|
1799
|
+
--sources <list> Exact sources to connect: google,slack,granola,messages,coding-sessions,all.
|
|
1800
|
+
--add-sources <list> Same as --sources, named for second-time onboarding.
|
|
1214
1801
|
--no-install-messages-agent
|
|
1215
1802
|
Save Messages credentials without starting launchd.
|
|
1803
|
+
--no-install-coding-sessions-agent
|
|
1804
|
+
Save coding-session credentials without starting launchd.
|
|
1216
1805
|
--no-open Print auth URLs instead of opening the browser.
|
|
1217
1806
|
--no-permission-prompt Print macOS permission instructions without waiting for confirmation.
|
|
1218
1807
|
--api <url> Advanced: Shepherd API URL.
|
|
@@ -1245,6 +1834,16 @@ function printAgentContract() {
|
|
|
1245
1834
|
const payload = {
|
|
1246
1835
|
task: "Complete Shepherd customer-facing production raw-source onboarding for this user with the public customer setup flow.",
|
|
1247
1836
|
instruction: "Use this as a public onboarding checklist. Confirm required onboarding choices and fields directly with the user instead of assuming them from local repo context, shell identity, machine paths, or earlier conversations.",
|
|
1837
|
+
commonUserRequests: [
|
|
1838
|
+
{
|
|
1839
|
+
userSays: "Check what I've enabled for Shepherd.",
|
|
1840
|
+
agentShould: `Run ${command} status and summarize connected sources, local background agents, unreadable folders, queued items, and any next command it prints.`,
|
|
1841
|
+
},
|
|
1842
|
+
{
|
|
1843
|
+
userSays: "Help me set up coding agent sessions.",
|
|
1844
|
+
agentShould: `Ask for consent to sync Codex and Claude Code summaries, then run ${command} agent --login if there is no saved Shepherd login, ${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>", ${command} agent --continue, and finally ${command} status.`,
|
|
1845
|
+
},
|
|
1846
|
+
],
|
|
1248
1847
|
askStyle: [
|
|
1249
1848
|
"Ask in short interactive prompts, not as a pasted checklist.",
|
|
1250
1849
|
"Start with selection questions to determine what the user wants connected.",
|
|
@@ -1255,9 +1854,11 @@ function printAgentContract() {
|
|
|
1255
1854
|
"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
1855
|
"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
1856
|
"Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
|
|
1857
|
+
"Ask Coding Sessions as a selectable choice: Skip Coding Sessions, or Sync Codex/Claude Code session summaries.",
|
|
1258
1858
|
"If the user chooses Provide handle, ask for the phone number or Apple ID email.",
|
|
1259
1859
|
"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.",
|
|
1860
|
+
"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.",
|
|
1861
|
+
"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
1862
|
"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
1863
|
],
|
|
1263
1864
|
selectionQuestions: [
|
|
@@ -1269,7 +1870,7 @@ function printAgentContract() {
|
|
|
1269
1870
|
{
|
|
1270
1871
|
label: "Sources",
|
|
1271
1872
|
prompt: "Which sources should Shepherd connect for raw sync?",
|
|
1272
|
-
options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
|
|
1873
|
+
options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages", "Coding Sessions (Codex/Claude Code summaries)"],
|
|
1273
1874
|
multiSelect: true,
|
|
1274
1875
|
},
|
|
1275
1876
|
{
|
|
@@ -1290,6 +1891,7 @@ function printAgentContract() {
|
|
|
1290
1891
|
"Messages phone number or Apple ID email, if they want local Messages connected",
|
|
1291
1892
|
"Full Disk Access confirmation, if they want local Messages connected on macOS",
|
|
1292
1893
|
"Selected local Messages chats from the browser selector, if they want local Messages connected",
|
|
1894
|
+
"Coding-session sync consent, if they want Codex and Claude Code session summaries connected",
|
|
1293
1895
|
"MCP install targets after onboarding completes: Codex, Claude Code, Cursor, any subset, or none",
|
|
1294
1896
|
],
|
|
1295
1897
|
afterStartCommand: [
|
|
@@ -1308,22 +1910,31 @@ function printAgentContract() {
|
|
|
1308
1910
|
],
|
|
1309
1911
|
loginCommand: `${command} agent --login`,
|
|
1310
1912
|
startCommand: `${command} agent --name "<full_name>" --org "<organization>"`,
|
|
1913
|
+
addSourcesCommand: `${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"`,
|
|
1311
1914
|
continueCommand: `${command} agent --continue`,
|
|
1915
|
+
checkCommand: `${command} status`,
|
|
1312
1916
|
mcpLoginCommand: `${command} mcp-login`,
|
|
1313
1917
|
optionalContinueArgs: [
|
|
1314
1918
|
"--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",
|
|
1919
|
+
"--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
1920
|
"--granola-api-key \"<granola_key>\" if Granola is being connected",
|
|
1317
1921
|
],
|
|
1318
|
-
statusCommand: `${command}
|
|
1922
|
+
statusCommand: `${command} status`,
|
|
1923
|
+
agentStatusCommand: `${command} agent --status`,
|
|
1319
1924
|
messagesChatsCommand: `${command} messages-chats`,
|
|
1320
1925
|
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.",
|
|
1926
|
+
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
1927
|
nodeBinary: process.execPath,
|
|
1323
1928
|
},
|
|
1929
|
+
codingSessions: {
|
|
1930
|
+
sourceFlag: "--sources coding-sessions or --add-sources coding-sessions",
|
|
1931
|
+
statusCommand: `${command} coding-sessions-status`,
|
|
1932
|
+
localAgentCommand: `${command} coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json`,
|
|
1933
|
+
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.",
|
|
1934
|
+
},
|
|
1324
1935
|
googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
|
|
1325
1936
|
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
|
|
1937
|
+
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
1938
|
granolaApiKeyCommand: `${command} granola-api-keys`,
|
|
1328
1939
|
granolaApiKeyPath: "Granola desktop app -> Settings -> Connectors -> API keys",
|
|
1329
1940
|
};
|
|
@@ -1341,9 +1952,19 @@ Confirm onboarding choices and fields directly with the user instead of assuming
|
|
|
1341
1952
|
|
|
1342
1953
|
Ask with short interactive prompts, not as one pasted checklist. Do not paste this whole checklist into the chat unless the user explicitly asks to see it.
|
|
1343
1954
|
|
|
1955
|
+
Common user requests:
|
|
1956
|
+
- If the user asks "Check what I've enabled for Shepherd?", run:
|
|
1957
|
+
${payload.checkCommand}
|
|
1958
|
+
Then summarize connected sources, local background agents, unreadable folders, queued items, and the next command to fix anything missing.
|
|
1959
|
+
- If the user asks "Help me set up coding agent sessions", ask for consent to sync Codex and Claude Code summaries, then run login if needed, add only the coding-sessions source, continue setup, and finish by checking status:
|
|
1960
|
+
${payload.loginCommand}
|
|
1961
|
+
${payload.addSourcesCommand}
|
|
1962
|
+
${payload.continueCommand}
|
|
1963
|
+
${payload.checkCommand}
|
|
1964
|
+
|
|
1344
1965
|
Start with selection questions to determine intent:
|
|
1345
1966
|
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.
|
|
1967
|
+
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
1968
|
3. Messages, if selected: Skip Messages, or Provide handle.
|
|
1348
1969
|
4. MCP install after onboarding completes: Codex, Claude Code, Cursor, any subset, or none.
|
|
1349
1970
|
|
|
@@ -1369,9 +1990,9 @@ If Messages is selected, run:
|
|
|
1369
1990
|
|
|
1370
1991
|
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
1992
|
${payload.messagesPermissions.nodeBinary}
|
|
1372
|
-
Contacts permission may also appear when Shepherd resolves local contact names.
|
|
1993
|
+
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
1994
|
|
|
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
|
|
1995
|
+
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
1996
|
|
|
1376
1997
|
Then run:
|
|
1377
1998
|
${payload.startCommand}
|
|
@@ -1382,6 +2003,9 @@ Add skip flags for sources the user did not select:
|
|
|
1382
2003
|
- --no-granola
|
|
1383
2004
|
- --no-messages
|
|
1384
2005
|
|
|
2006
|
+
Or pass an exact source list, especially for adding sources later:
|
|
2007
|
+
${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"
|
|
2008
|
+
|
|
1385
2009
|
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
2010
|
|
|
1387
2011
|
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 +2033,18 @@ If Granola is the current modality and did not come forward, run:
|
|
|
1409
2033
|
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
2034
|
|
|
1411
2035
|
After the current modality is complete, run:
|
|
1412
|
-
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
2036
|
+
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"
|
|
1413
2037
|
|
|
1414
2038
|
Omit either optional flag if that source is not being connected.
|
|
1415
2039
|
|
|
2040
|
+
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.
|
|
2041
|
+
|
|
1416
2042
|
Check progress with:
|
|
1417
2043
|
${payload.statusCommand}
|
|
1418
2044
|
|
|
2045
|
+
Check local coding-session sync with:
|
|
2046
|
+
${payload.codingSessions.statusCommand}
|
|
2047
|
+
|
|
1419
2048
|
After onboarding completes, ask whether to install Shepherd MCP for the signed-in customer into Codex, Claude Code, Cursor, any subset, or none.
|
|
1420
2049
|
If they choose any targets, run:
|
|
1421
2050
|
${payload.mcpLoginCommand} --install "<codex,claude,cursor>"
|
|
@@ -1432,12 +2061,74 @@ function hasIdentityArgs() {
|
|
|
1432
2061
|
}
|
|
1433
2062
|
|
|
1434
2063
|
function selectedSources() {
|
|
1435
|
-
|
|
1436
|
-
|
|
1437
|
-
|
|
1438
|
-
|
|
1439
|
-
|
|
2064
|
+
const explicitSources = stringArg("sources") ?? stringArg("add-sources");
|
|
2065
|
+
const selected = explicitSources
|
|
2066
|
+
? sourceSelectionFromList(explicitSources)
|
|
2067
|
+
: {
|
|
2068
|
+
google: !args["no-google"],
|
|
2069
|
+
slack: !args["no-slack"],
|
|
2070
|
+
granola: !args["no-granola"],
|
|
2071
|
+
messages: !args["no-messages"],
|
|
2072
|
+
codingSessions: args["coding-sessions"] === true || args["coding-sessions"] === "true",
|
|
2073
|
+
};
|
|
2074
|
+
|
|
2075
|
+
if (args["no-google"]) selected.google = false;
|
|
2076
|
+
if (args["no-slack"]) selected.slack = false;
|
|
2077
|
+
if (args["no-granola"]) selected.granola = false;
|
|
2078
|
+
if (args["no-messages"]) selected.messages = false;
|
|
2079
|
+
if (args["no-coding-sessions"]) selected.codingSessions = false;
|
|
2080
|
+
if (args["coding-sessions"]) selected.codingSessions = true;
|
|
2081
|
+
|
|
2082
|
+
return selected;
|
|
2083
|
+
}
|
|
2084
|
+
|
|
2085
|
+
function sourceSelectionFromList(value) {
|
|
2086
|
+
const selected = {
|
|
2087
|
+
google: false,
|
|
2088
|
+
slack: false,
|
|
2089
|
+
granola: false,
|
|
2090
|
+
messages: false,
|
|
2091
|
+
codingSessions: false,
|
|
1440
2092
|
};
|
|
2093
|
+
const aliases = new Map([
|
|
2094
|
+
["google", "google"],
|
|
2095
|
+
["gmail", "google"],
|
|
2096
|
+
["drive", "google"],
|
|
2097
|
+
["docs", "google"],
|
|
2098
|
+
["gdocs", "google"],
|
|
2099
|
+
["calendar", "google"],
|
|
2100
|
+
["slack", "slack"],
|
|
2101
|
+
["granola", "granola"],
|
|
2102
|
+
["messages", "messages"],
|
|
2103
|
+
["imessage", "messages"],
|
|
2104
|
+
["imessages", "messages"],
|
|
2105
|
+
["coding-sessions", "codingSessions"],
|
|
2106
|
+
["coding_sessions", "codingSessions"],
|
|
2107
|
+
["codingsessions", "codingSessions"],
|
|
2108
|
+
["sessions", "codingSessions"],
|
|
2109
|
+
["codex", "codingSessions"],
|
|
2110
|
+
["claude", "codingSessions"],
|
|
2111
|
+
["claude-code", "codingSessions"],
|
|
2112
|
+
]);
|
|
2113
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
2114
|
+
if (!raw) throw new Error("--sources requires at least one source.");
|
|
2115
|
+
const parts = raw.split(/[,\s]+/).filter(Boolean);
|
|
2116
|
+
for (const part of parts) {
|
|
2117
|
+
if (part === "all") {
|
|
2118
|
+
selected.google = true;
|
|
2119
|
+
selected.slack = true;
|
|
2120
|
+
selected.granola = true;
|
|
2121
|
+
selected.messages = true;
|
|
2122
|
+
selected.codingSessions = true;
|
|
2123
|
+
continue;
|
|
2124
|
+
}
|
|
2125
|
+
const source = aliases.get(part);
|
|
2126
|
+
if (!source) {
|
|
2127
|
+
throw new Error(`Unknown source "${part}". Use google, slack, granola, messages, coding-sessions, or all.`);
|
|
2128
|
+
}
|
|
2129
|
+
selected[source] = true;
|
|
2130
|
+
}
|
|
2131
|
+
return selected;
|
|
1441
2132
|
}
|
|
1442
2133
|
|
|
1443
2134
|
async function writeAgentState(state) {
|
|
@@ -1618,6 +2309,16 @@ async function openNextAgentModality({ sources, authUrls = {}, noOpen = false, p
|
|
|
1618
2309
|
message: "Run the local Messages chat selector and keep the printed chat IDs.",
|
|
1619
2310
|
};
|
|
1620
2311
|
}
|
|
2312
|
+
|
|
2313
|
+
if (source === "codingSessions") {
|
|
2314
|
+
return {
|
|
2315
|
+
source,
|
|
2316
|
+
label: "Coding Sessions",
|
|
2317
|
+
opened: false,
|
|
2318
|
+
command: `${agentCommand()} agent --continue`,
|
|
2319
|
+
message: "Continue onboarding to install local Codex and Claude Code session sync.",
|
|
2320
|
+
};
|
|
2321
|
+
}
|
|
1621
2322
|
}
|
|
1622
2323
|
|
|
1623
2324
|
return null;
|
|
@@ -1665,6 +2366,12 @@ function printAgentCurrentAction(action, opts = {}) {
|
|
|
1665
2366
|
console.log(`Run: ${action.command}`);
|
|
1666
2367
|
console.log("Have the user select specific local Messages chats; do not select all by default.");
|
|
1667
2368
|
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.");
|
|
2369
|
+
return;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
if (action.source === "codingSessions") {
|
|
2373
|
+
console.log(`Run: ${action.command}`);
|
|
2374
|
+
console.log("This installs a local LaunchAgent that summarizes Codex and Claude Code session logs and syncs those summaries to Shepherd.");
|
|
1668
2375
|
}
|
|
1669
2376
|
}
|
|
1670
2377
|
|
|
@@ -1674,6 +2381,7 @@ function agentNeedsUserAction(sources, action) {
|
|
|
1674
2381
|
if (action.source === "slack") return ["Complete Slack browser authorization."];
|
|
1675
2382
|
if (action.source === "granola") return ["Create/copy a Granola API key from the Granola Mac app."];
|
|
1676
2383
|
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."];
|
|
2384
|
+
if (action.source === "codingSessions") return ["Run the continue command to install local Codex and Claude Code session summary sync."];
|
|
1677
2385
|
return [];
|
|
1678
2386
|
}
|
|
1679
2387
|
|
|
@@ -1828,7 +2536,7 @@ async function explainMessagesBackgroundPermissions(opts = {}) {
|
|
|
1828
2536
|
console.log("\nMessages background sync permissions");
|
|
1829
2537
|
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
2538
|
printMessagesPermissionTargets();
|
|
1831
|
-
console.log("Contacts permission may also appear when Shepherd resolves local contact names for
|
|
2539
|
+
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
2540
|
await openFullDiskAccessSettings(opts);
|
|
1833
2541
|
|
|
1834
2542
|
if (opts.waitForUser && process.stdin.isTTY && !args["no-permission-prompt"]) {
|
|
@@ -1940,8 +2648,9 @@ async function writeMessagesConfig(input) {
|
|
|
1940
2648
|
await mkdir(dir, { recursive: true });
|
|
1941
2649
|
const path = join(dir, `${input.userId}.json`);
|
|
1942
2650
|
const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
|
|
1943
|
-
|
|
1944
|
-
|
|
2651
|
+
const allChats = selectedChatIdsIncludeAll(allowedChatIds);
|
|
2652
|
+
if (!allChats && allowedChatIds.length === 0) {
|
|
2653
|
+
throw new Error("Select at least one Messages chat or pass --messages-chat-ids all before installing local Messages sync.");
|
|
1945
2654
|
}
|
|
1946
2655
|
await writeFile(
|
|
1947
2656
|
path,
|
|
@@ -1950,8 +2659,10 @@ async function writeMessagesConfig(input) {
|
|
|
1950
2659
|
userId: input.userId,
|
|
1951
2660
|
agentToken: input.agentToken,
|
|
1952
2661
|
backfillDays: input.backfillDays,
|
|
1953
|
-
|
|
2662
|
+
allChats,
|
|
2663
|
+
allowedChatIds: allChats ? [] : allowedChatIds,
|
|
1954
2664
|
selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
|
|
2665
|
+
excludedMessageHandles: SHEPHERD_OWNED_MESSAGE_HANDLES,
|
|
1955
2666
|
createdAt: new Date().toISOString(),
|
|
1956
2667
|
}, null, 2),
|
|
1957
2668
|
{ mode: 0o600 },
|
|
@@ -2036,18 +2747,122 @@ async function installMessagesAgent(configPath, userId) {
|
|
|
2036
2747
|
return { label, plistPath, stdoutPath, stderrPath };
|
|
2037
2748
|
}
|
|
2038
2749
|
|
|
2039
|
-
async function
|
|
2040
|
-
const
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2045
|
-
|
|
2046
|
-
|
|
2750
|
+
async function writeCodingSessionsConfig(input) {
|
|
2751
|
+
const dir = join(homedir(), ".shepherd", "coding-sessions");
|
|
2752
|
+
await mkdir(dir, { recursive: true });
|
|
2753
|
+
const path = join(dir, `${input.userId}.json`);
|
|
2754
|
+
await writeFile(
|
|
2755
|
+
path,
|
|
2756
|
+
JSON.stringify({
|
|
2757
|
+
apiUrl: input.apiUrl,
|
|
2758
|
+
userId: input.userId,
|
|
2759
|
+
agentToken: input.agentToken,
|
|
2760
|
+
intervalSeconds: Math.max(15, Math.floor(Number(input.intervalSeconds) || 60)),
|
|
2761
|
+
codexDirs: [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR],
|
|
2762
|
+
claudeProjectsDir: CLAUDE_PROJECTS_DIR,
|
|
2763
|
+
maxFilesPerProvider: 300,
|
|
2764
|
+
createdAt: new Date().toISOString(),
|
|
2765
|
+
}, null, 2),
|
|
2766
|
+
{ mode: 0o600 },
|
|
2767
|
+
);
|
|
2768
|
+
return path;
|
|
2769
|
+
}
|
|
2047
2770
|
|
|
2048
|
-
|
|
2049
|
-
|
|
2050
|
-
|
|
2771
|
+
async function installCodingSessionsAgent(configPath, userId) {
|
|
2772
|
+
if (platform() !== "darwin") {
|
|
2773
|
+
throw new Error("automatic local coding-session sync is only supported on macOS");
|
|
2774
|
+
}
|
|
2775
|
+
|
|
2776
|
+
const safeId = safeFileId(userId);
|
|
2777
|
+
const label = `ai.shepherd.coding-sessions.${safeId}`;
|
|
2778
|
+
const rawDir = join(homedir(), ".shepherd", "coding-sessions");
|
|
2779
|
+
const agentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
2780
|
+
await mkdir(rawDir, { recursive: true });
|
|
2781
|
+
await mkdir(agentsDir, { recursive: true });
|
|
2782
|
+
|
|
2783
|
+
const plistPath = join(agentsDir, `${label}.plist`);
|
|
2784
|
+
const stdoutPath = join(rawDir, `${safeId}.out.log`);
|
|
2785
|
+
const stderrPath = join(rawDir, `${safeId}.err.log`);
|
|
2786
|
+
const launchPath = launchAgentPath();
|
|
2787
|
+
|
|
2788
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2789
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2790
|
+
<plist version="1.0">
|
|
2791
|
+
<dict>
|
|
2792
|
+
<key>Label</key>
|
|
2793
|
+
<string>${xmlEscape(label)}</string>
|
|
2794
|
+
<key>ProgramArguments</key>
|
|
2795
|
+
<array>
|
|
2796
|
+
<string>/usr/bin/env</string>
|
|
2797
|
+
<string>npx</string>
|
|
2798
|
+
<string>-y</string>
|
|
2799
|
+
<string>${PACKAGE_SPEC}</string>
|
|
2800
|
+
<string>coding-sessions-agent</string>
|
|
2801
|
+
<string>--config</string>
|
|
2802
|
+
<string>${xmlEscape(configPath)}</string>
|
|
2803
|
+
</array>
|
|
2804
|
+
<key>KeepAlive</key>
|
|
2805
|
+
<true/>
|
|
2806
|
+
<key>RunAtLoad</key>
|
|
2807
|
+
<true/>
|
|
2808
|
+
<key>StandardOutPath</key>
|
|
2809
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
2810
|
+
<key>StandardErrorPath</key>
|
|
2811
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
2812
|
+
<key>EnvironmentVariables</key>
|
|
2813
|
+
<dict>
|
|
2814
|
+
<key>PATH</key>
|
|
2815
|
+
<string>${xmlEscape(launchPath)}</string>
|
|
2816
|
+
</dict>
|
|
2817
|
+
</dict>
|
|
2818
|
+
</plist>
|
|
2819
|
+
`;
|
|
2820
|
+
|
|
2821
|
+
await writeFile(plistPath, plist, { mode: 0o600 });
|
|
2822
|
+
const stdoutOffset = await fileLength(stdoutPath);
|
|
2823
|
+
const stderrOffset = await fileLength(stderrPath);
|
|
2824
|
+
await execFileQuiet("launchctl", ["unload", plistPath], { ignoreError: true });
|
|
2825
|
+
await execFileQuiet("launchctl", ["load", plistPath]);
|
|
2826
|
+
await execFileQuiet("launchctl", ["start", label], { ignoreError: true });
|
|
2827
|
+
await verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset });
|
|
2828
|
+
return { label, plistPath, stdoutPath, stderrPath };
|
|
2829
|
+
}
|
|
2830
|
+
|
|
2831
|
+
async function verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
|
|
2832
|
+
const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
|
|
2833
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2834
|
+
await sleep(1000);
|
|
2835
|
+
const stdout = await readFileFrom(stdoutPath, stdoutOffset);
|
|
2836
|
+
const stderr = await readFileFrom(stderrPath, stderrOffset);
|
|
2837
|
+
const launchState = readLaunchctlPrint(domainLabel);
|
|
2838
|
+
|
|
2839
|
+
if (/Shepherd Coding Sessions sync starting|Coding session scan/i.test(stdout)
|
|
2840
|
+
&& /state = running|job state = running/i.test(launchState)) {
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
if (/EACCES|operation not permitted|permission denied/i.test(stderr)) {
|
|
2844
|
+
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.");
|
|
2845
|
+
}
|
|
2846
|
+
if (/last exit code = [1-9]|job state = exited|state = spawn scheduled/i.test(launchState) && stderr.trim()) {
|
|
2847
|
+
throw new Error(`Coding-session sync exited during startup: ${firstMeaningfulLine(stderr)}`);
|
|
2848
|
+
}
|
|
2849
|
+
}
|
|
2850
|
+
|
|
2851
|
+
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.");
|
|
2852
|
+
}
|
|
2853
|
+
|
|
2854
|
+
async function verifyMessagesAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
|
|
2855
|
+
const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
|
|
2856
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
2857
|
+
await sleep(1500);
|
|
2858
|
+
|
|
2859
|
+
const stdout = await readFileFrom(stdoutPath, stdoutOffset);
|
|
2860
|
+
const stderr = await readFileFrom(stderrPath, stderrOffset);
|
|
2861
|
+
const launchState = readLaunchctlPrint(domainLabel);
|
|
2862
|
+
|
|
2863
|
+
if (/Failed to open database|unable to open database file/i.test(stderr)) {
|
|
2864
|
+
throw new Error("Messages background sync could not open local Messages storage. Grant macOS Full Disk Access to the app running onboarding and Node.js, then rerun or continue the Messages step.");
|
|
2865
|
+
}
|
|
2051
2866
|
|
|
2052
2867
|
if (/Watching for new Messages|Shepherd Messages raw sync starting|Running .*Messages backfill/i.test(stdout)
|
|
2053
2868
|
&& /state = running|job state = running/i.test(launchState)) {
|
|
@@ -2071,6 +2886,7 @@ function readLaunchctlPrint(domainLabel) {
|
|
|
2071
2886
|
try {
|
|
2072
2887
|
return execFileSync("launchctl", ["print", domainLabel], {
|
|
2073
2888
|
encoding: "utf8",
|
|
2889
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2074
2890
|
timeout: 5_000,
|
|
2075
2891
|
});
|
|
2076
2892
|
} catch (err) {
|
|
@@ -2121,7 +2937,7 @@ async function selectRecentMessageChats() {
|
|
|
2121
2937
|
}
|
|
2122
2938
|
|
|
2123
2939
|
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>".`);
|
|
2940
|
+
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
2941
|
}
|
|
2126
2942
|
|
|
2127
2943
|
const chats = await listRecentMessageChats({ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500) });
|
|
@@ -2135,11 +2951,13 @@ async function selectRecentMessageChats() {
|
|
|
2135
2951
|
|
|
2136
2952
|
console.log(`\nSelect local Messages chats to sync\n`);
|
|
2137
2953
|
console.log("Shepherd will only pull from the chats you select.");
|
|
2954
|
+
console.log("Enter all to sync every current chat and keep watching future new chats.");
|
|
2138
2955
|
for (let i = 0; i < chats.length; i++) {
|
|
2139
2956
|
console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
|
|
2140
2957
|
}
|
|
2141
2958
|
|
|
2142
|
-
const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
|
|
2959
|
+
const answer = await prompt("\nEnter chat numbers to sync, separated by commas, or all: ");
|
|
2960
|
+
if (String(answer ?? "").trim().toLowerCase() === "all") return [allMessagesChatsSelection()];
|
|
2143
2961
|
const indexes = parseSelectionIndexes(answer, chats.length);
|
|
2144
2962
|
if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
|
|
2145
2963
|
return indexes.map((idx) => chats[idx]);
|
|
@@ -2196,6 +3014,16 @@ async function selectChatsInBrowser(chats, opts = {}) {
|
|
|
2196
3014
|
sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
|
|
2197
3015
|
return;
|
|
2198
3016
|
}
|
|
3017
|
+
if (form.get("allChats") === "1") {
|
|
3018
|
+
if (!settled) res.once("finish", finishBrowserSelection);
|
|
3019
|
+
sendHtml(res, renderMessagesDonePage("All current and future chats selected."));
|
|
3020
|
+
if (!settled) {
|
|
3021
|
+
settled = true;
|
|
3022
|
+
clearTimeout(timeout);
|
|
3023
|
+
resolve([allMessagesChatsSelection()]);
|
|
3024
|
+
}
|
|
3025
|
+
return;
|
|
3026
|
+
}
|
|
2199
3027
|
const selectedIds = form.getAll("chatId").filter(Boolean);
|
|
2200
3028
|
const selectedSet = new Set(selectedIds);
|
|
2201
3029
|
const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
|
|
@@ -2385,6 +3213,31 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2385
3213
|
outline-offset: 1px;
|
|
2386
3214
|
}
|
|
2387
3215
|
.search::placeholder { color: var(--faint); }
|
|
3216
|
+
.all-chats {
|
|
3217
|
+
display: grid;
|
|
3218
|
+
grid-template-columns: 20px minmax(0, 1fr);
|
|
3219
|
+
gap: 12px;
|
|
3220
|
+
align-items: start;
|
|
3221
|
+
margin-top: 12px;
|
|
3222
|
+
padding: 12px;
|
|
3223
|
+
border: 1px solid var(--line);
|
|
3224
|
+
border-radius: 8px;
|
|
3225
|
+
cursor: pointer;
|
|
3226
|
+
background: #fbfbfa;
|
|
3227
|
+
}
|
|
3228
|
+
.all-chats:hover { border-color: var(--green); }
|
|
3229
|
+
.all-title {
|
|
3230
|
+
display: block;
|
|
3231
|
+
font-size: 14px;
|
|
3232
|
+
font-weight: 600;
|
|
3233
|
+
}
|
|
3234
|
+
.all-copy {
|
|
3235
|
+
display: block;
|
|
3236
|
+
margin-top: 2px;
|
|
3237
|
+
color: var(--muted);
|
|
3238
|
+
font-size: 12.5px;
|
|
3239
|
+
line-height: 1.35;
|
|
3240
|
+
}
|
|
2388
3241
|
.error {
|
|
2389
3242
|
margin: 0 0 12px;
|
|
2390
3243
|
color: #9B1C1C;
|
|
@@ -2537,6 +3390,14 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2537
3390
|
<div class="panel">
|
|
2538
3391
|
<div class="panel-head">
|
|
2539
3392
|
<input class="search" id="search" type="search" placeholder="Search contacts or groups" autocomplete="off">
|
|
3393
|
+
<label class="all-chats">
|
|
3394
|
+
<input type="checkbox" id="all-chats" name="allChats" value="1">
|
|
3395
|
+
<span class="box" aria-hidden="true"></span>
|
|
3396
|
+
<span>
|
|
3397
|
+
<span class="all-title">Sync all current and future chats</span>
|
|
3398
|
+
<span class="all-copy">Backfill every current Messages chat and keep watching chats that appear later.</span>
|
|
3399
|
+
</span>
|
|
3400
|
+
</label>
|
|
2540
3401
|
</div>
|
|
2541
3402
|
${error ? `<p class="error">${html(error)}</p>` : ""}
|
|
2542
3403
|
<div class="list-head">
|
|
@@ -2562,6 +3423,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2562
3423
|
const selected = document.getElementById("selection-count");
|
|
2563
3424
|
const form = document.querySelector("form");
|
|
2564
3425
|
const checks = Array.from(document.querySelectorAll('input[name="chatId"]'));
|
|
3426
|
+
const allChats = document.getElementById("all-chats");
|
|
2565
3427
|
|
|
2566
3428
|
function updateRows() {
|
|
2567
3429
|
const query = search.value.trim().toLowerCase();
|
|
@@ -2577,6 +3439,12 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2577
3439
|
}
|
|
2578
3440
|
|
|
2579
3441
|
function updateSelected() {
|
|
3442
|
+
if (allChats.checked) {
|
|
3443
|
+
selected.textContent = "All current and future chats";
|
|
3444
|
+
for (const check of checks) check.disabled = true;
|
|
3445
|
+
return;
|
|
3446
|
+
}
|
|
3447
|
+
for (const check of checks) check.disabled = false;
|
|
2580
3448
|
const count = checks.filter((check) => check.checked).length;
|
|
2581
3449
|
selected.textContent = count + " selected";
|
|
2582
3450
|
}
|
|
@@ -2587,6 +3455,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2587
3455
|
event.preventDefault();
|
|
2588
3456
|
form.requestSubmit();
|
|
2589
3457
|
});
|
|
3458
|
+
allChats.addEventListener("change", updateSelected);
|
|
2590
3459
|
for (const check of checks) check.addEventListener("change", updateSelected);
|
|
2591
3460
|
updateRows();
|
|
2592
3461
|
updateSelected();
|
|
@@ -2757,7 +3626,8 @@ async function listRecentMessageChats({ limit }) {
|
|
|
2757
3626
|
|
|
2758
3627
|
const enriched = [];
|
|
2759
3628
|
for (const chat of visible) {
|
|
2760
|
-
|
|
3629
|
+
const candidate = await enrichMessageChat(sdk, chat, contactLookup);
|
|
3630
|
+
if (!messageChatTouchesShepherdAgent(candidate)) enriched.push(candidate);
|
|
2761
3631
|
}
|
|
2762
3632
|
return enriched;
|
|
2763
3633
|
} finally {
|
|
@@ -2765,6 +3635,41 @@ async function listRecentMessageChats({ limit }) {
|
|
|
2765
3635
|
}
|
|
2766
3636
|
}
|
|
2767
3637
|
|
|
3638
|
+
async function loadSelectedMessageChatsForConfig(chatIds) {
|
|
3639
|
+
const allowedChatIds = parseAllowedChatIds(chatIds);
|
|
3640
|
+
if (allowedChatIds.length === 0 || selectedChatIdsIncludeAll(allowedChatIds)) return [];
|
|
3641
|
+
if (platform() !== "darwin") return [];
|
|
3642
|
+
|
|
3643
|
+
const kit = await import("@photon-ai/imessage-kit");
|
|
3644
|
+
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
3645
|
+
const contactLookup = buildContactLookup();
|
|
3646
|
+
try {
|
|
3647
|
+
const listedChats = typeof sdk.listChats === "function"
|
|
3648
|
+
? await sdk.listChats({ sortBy: "recent", limit: 5_000 }).catch(() => [])
|
|
3649
|
+
: [];
|
|
3650
|
+
const byId = new Map(listedChats
|
|
3651
|
+
.filter((chat) => typeof chat?.chatId === "string")
|
|
3652
|
+
.map((chat) => [chat.chatId, chat]));
|
|
3653
|
+
|
|
3654
|
+
const selected = [];
|
|
3655
|
+
for (const chatId of allowedChatIds) {
|
|
3656
|
+
const listed = byId.get(chatId);
|
|
3657
|
+
const fallback = {
|
|
3658
|
+
chatId,
|
|
3659
|
+
kind: parseDmHandleFromChatId(chatId) ? "dm" : "group",
|
|
3660
|
+
name: null,
|
|
3661
|
+
service: null,
|
|
3662
|
+
lastMessageAt: null,
|
|
3663
|
+
};
|
|
3664
|
+
const candidate = await enrichMessageChat(sdk, { ...fallback, ...listed, chatId }, contactLookup);
|
|
3665
|
+
if (!messageChatTouchesShepherdAgent(candidate)) selected.push(candidate);
|
|
3666
|
+
}
|
|
3667
|
+
return selected;
|
|
3668
|
+
} finally {
|
|
3669
|
+
await sdk.close?.().catch(() => undefined);
|
|
3670
|
+
}
|
|
3671
|
+
}
|
|
3672
|
+
|
|
2768
3673
|
async function enrichMessageChat(sdk, chat, contactLookup) {
|
|
2769
3674
|
const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
|
|
2770
3675
|
const participants = uniqueParticipants(recentMessages, contactLookup);
|
|
@@ -2795,7 +3700,7 @@ function uniqueParticipants(messages, contactLookup) {
|
|
|
2795
3700
|
const participants = [];
|
|
2796
3701
|
for (const msg of messages) {
|
|
2797
3702
|
const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
|
|
2798
|
-
if (!handle || contactLookup.isSelfHandle(handle)) continue;
|
|
3703
|
+
if (!handle || contactLookup.isSelfHandle(handle) || isShepherdAgentMessageHandle(handle)) continue;
|
|
2799
3704
|
const normalized = normalizeHandle(handle);
|
|
2800
3705
|
if (seen.has(normalized)) continue;
|
|
2801
3706
|
seen.add(normalized);
|
|
@@ -2825,6 +3730,21 @@ function publicMessageChat(chat) {
|
|
|
2825
3730
|
};
|
|
2826
3731
|
}
|
|
2827
3732
|
|
|
3733
|
+
function allMessagesChatsSelection() {
|
|
3734
|
+
return {
|
|
3735
|
+
chatId: ALL_MESSAGES_CHATS,
|
|
3736
|
+
label: "All current and future chats",
|
|
3737
|
+
kind: "all",
|
|
3738
|
+
service: null,
|
|
3739
|
+
lastMessageAt: null,
|
|
3740
|
+
participants: [],
|
|
3741
|
+
};
|
|
3742
|
+
}
|
|
3743
|
+
|
|
3744
|
+
function selectedIncludesAllChats(chats) {
|
|
3745
|
+
return Array.isArray(chats) && chats.some((chat) => chat?.chatId === ALL_MESSAGES_CHATS);
|
|
3746
|
+
}
|
|
3747
|
+
|
|
2828
3748
|
async function loadGroupChatNames(sdk, serializer) {
|
|
2829
3749
|
if (typeof sdk.listChats !== "function") return;
|
|
2830
3750
|
try {
|
|
@@ -2847,21 +3767,67 @@ function loadSelectedChatNames(selectedChats, serializer) {
|
|
|
2847
3767
|
}
|
|
2848
3768
|
}
|
|
2849
3769
|
|
|
2850
|
-
|
|
2851
|
-
|
|
3770
|
+
function selectedChatContactSeedHandles(selectedChats, allowedChatIds) {
|
|
3771
|
+
const handles = [];
|
|
3772
|
+
for (const chatId of allowedChatIds ?? []) {
|
|
3773
|
+
const dmHandle = parseDmHandleFromChatId(chatId);
|
|
3774
|
+
if (dmHandle && !isShepherdAgentMessageHandle(dmHandle)) handles.push(dmHandle);
|
|
3775
|
+
}
|
|
3776
|
+
for (const chat of Array.isArray(selectedChats) ? selectedChats : []) {
|
|
3777
|
+
for (const participant of Array.isArray(chat?.participants) ? chat.participants : []) {
|
|
3778
|
+
if (
|
|
3779
|
+
typeof participant?.handle === "string"
|
|
3780
|
+
&& participant.handle.trim()
|
|
3781
|
+
&& !isShepherdAgentMessageHandle(participant.handle)
|
|
3782
|
+
) {
|
|
3783
|
+
handles.push(participant.handle);
|
|
3784
|
+
}
|
|
3785
|
+
}
|
|
3786
|
+
}
|
|
3787
|
+
return [...new Set(handles)];
|
|
3788
|
+
}
|
|
3789
|
+
|
|
3790
|
+
async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null) {
|
|
3791
|
+
const allChats = allowedChatIds == null;
|
|
3792
|
+
console.log(allChats
|
|
3793
|
+
? `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for all current chats`
|
|
3794
|
+
: `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for ${allowedChatIds.length} selected chat(s)`);
|
|
2852
3795
|
const since = days == null ? null : new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
2853
3796
|
const pageSize = 1000;
|
|
2854
3797
|
let totalMessages = 0;
|
|
2855
3798
|
let totalStored = 0;
|
|
2856
3799
|
|
|
3800
|
+
if (allChats) {
|
|
3801
|
+
let offset = 0;
|
|
3802
|
+
while (true) {
|
|
3803
|
+
const messages = await sdk.getMessages({ ...(since ? { since } : {}), limit: pageSize, offset });
|
|
3804
|
+
if (!messages.length) break;
|
|
3805
|
+
const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
|
|
3806
|
+
|
|
3807
|
+
contactSync?.observeMessages(filtered);
|
|
3808
|
+
totalMessages += filtered.length;
|
|
3809
|
+
const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
|
|
3810
|
+
totalStored += result.stored;
|
|
3811
|
+
saveMessagesWatermark(sender.userId, maxRowId(messages));
|
|
3812
|
+
|
|
3813
|
+
if (messages.length < pageSize) break;
|
|
3814
|
+
offset += pageSize;
|
|
3815
|
+
}
|
|
3816
|
+
|
|
3817
|
+
console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
|
|
3818
|
+
return;
|
|
3819
|
+
}
|
|
3820
|
+
|
|
2857
3821
|
for (const chatId of allowedChatIds) {
|
|
2858
3822
|
let offset = 0;
|
|
2859
3823
|
while (true) {
|
|
2860
3824
|
const messages = await sdk.getMessages({ chatId, ...(since ? { since } : {}), limit: pageSize, offset });
|
|
2861
3825
|
if (!messages.length) break;
|
|
3826
|
+
const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
|
|
2862
3827
|
|
|
2863
|
-
|
|
2864
|
-
|
|
3828
|
+
contactSync?.observeMessages(filtered);
|
|
3829
|
+
totalMessages += filtered.length;
|
|
3830
|
+
const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
|
|
2865
3831
|
totalStored += result.stored;
|
|
2866
3832
|
saveMessagesWatermark(sender.userId, maxRowId(messages));
|
|
2867
3833
|
|
|
@@ -2873,24 +3839,35 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
|
|
|
2873
3839
|
console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
|
|
2874
3840
|
}
|
|
2875
3841
|
|
|
2876
|
-
async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
|
|
3842
|
+
async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null) {
|
|
3843
|
+
const allChats = allowedChatIds == null;
|
|
2877
3844
|
const lastWatermark = loadMessagesWatermark(userId);
|
|
2878
3845
|
if (lastWatermark <= 0) return;
|
|
2879
3846
|
|
|
2880
3847
|
const missed = [];
|
|
2881
|
-
|
|
2882
|
-
missed.push(...await sdk.getMessages({
|
|
3848
|
+
if (allChats) {
|
|
3849
|
+
missed.push(...await sdk.getMessages({ limit: 5000 }));
|
|
3850
|
+
} else {
|
|
3851
|
+
for (const chatId of allowedChatIds) {
|
|
3852
|
+
missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
|
|
3853
|
+
}
|
|
2883
3854
|
}
|
|
2884
|
-
const newMessages = missed.filter((msg) =>
|
|
3855
|
+
const newMessages = missed.filter((msg) =>
|
|
3856
|
+
Number(msg.rowId) > lastWatermark
|
|
3857
|
+
&& (allChats || allowedChatIds.includes(msg.chatId))
|
|
3858
|
+
&& !messageTouchesShepherdAgent(msg));
|
|
2885
3859
|
if (newMessages.length === 0) return;
|
|
2886
3860
|
|
|
3861
|
+
contactSync?.observeMessages(newMessages);
|
|
2887
3862
|
const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
|
|
2888
3863
|
if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(newMessages));
|
|
2889
3864
|
console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
|
|
2890
3865
|
}
|
|
2891
3866
|
|
|
2892
|
-
async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
2893
|
-
const
|
|
3867
|
+
async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, opts = {}) {
|
|
3868
|
+
const allChats = allowedChatIds == null;
|
|
3869
|
+
const allowed = new Set(allowedChatIds ?? []);
|
|
3870
|
+
const contactSync = opts.contactSync ?? null;
|
|
2894
3871
|
let buffer = [];
|
|
2895
3872
|
let timer = null;
|
|
2896
3873
|
|
|
@@ -2910,7 +3887,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2910
3887
|
};
|
|
2911
3888
|
|
|
2912
3889
|
const onMessage = (msg) => {
|
|
2913
|
-
if (!msg.chatId || !allowed.has(msg.chatId)) return;
|
|
3890
|
+
if (!msg.chatId || (!allChats && !allowed.has(msg.chatId))) return;
|
|
3891
|
+
if (messageTouchesShepherdAgent(msg)) return;
|
|
3892
|
+
contactSync?.observeMessages([msg]);
|
|
2914
3893
|
buffer.push(msg);
|
|
2915
3894
|
if (buffer.length >= MAX_BATCH_SIZE) {
|
|
2916
3895
|
if (timer) clearTimeout(timer);
|
|
@@ -2927,7 +3906,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2927
3906
|
onError: (err) => console.error("Messages watcher error:", safeError(err)),
|
|
2928
3907
|
});
|
|
2929
3908
|
|
|
2930
|
-
console.log(
|
|
3909
|
+
console.log(allChats
|
|
3910
|
+
? "Watching for new Messages in all current and future chats"
|
|
3911
|
+
: "Watching for new Messages in selected chats");
|
|
2931
3912
|
|
|
2932
3913
|
await new Promise((resolve) => {
|
|
2933
3914
|
let stopping = false;
|
|
@@ -2935,6 +3916,7 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2935
3916
|
if (stopping) return;
|
|
2936
3917
|
stopping = true;
|
|
2937
3918
|
if (timer) clearTimeout(timer);
|
|
3919
|
+
contactSync?.stop();
|
|
2938
3920
|
await flush().catch(() => undefined);
|
|
2939
3921
|
await sdk.close?.().catch(() => undefined);
|
|
2940
3922
|
resolve();
|
|
@@ -2945,6 +3927,161 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2945
3927
|
});
|
|
2946
3928
|
}
|
|
2947
3929
|
|
|
3930
|
+
function createMutableContactLookup(initial = emptyContactLookup()) {
|
|
3931
|
+
let current = initial;
|
|
3932
|
+
return {
|
|
3933
|
+
replace(next) {
|
|
3934
|
+
current = next ?? emptyContactLookup();
|
|
3935
|
+
},
|
|
3936
|
+
resolveName(handle) {
|
|
3937
|
+
return current.resolveName(handle);
|
|
3938
|
+
},
|
|
3939
|
+
isSelfHandle(handle) {
|
|
3940
|
+
return current.isSelfHandle(handle);
|
|
3941
|
+
},
|
|
3942
|
+
mappings() {
|
|
3943
|
+
return contactMappingsFromLookup(current);
|
|
3944
|
+
},
|
|
3945
|
+
};
|
|
3946
|
+
}
|
|
3947
|
+
|
|
3948
|
+
function startMessagesContactSync(sender, contactLookup, opts = {}) {
|
|
3949
|
+
const syncAllContacts = opts.syncAllContacts === true;
|
|
3950
|
+
const observedHandles = new Set();
|
|
3951
|
+
for (const handle of opts.seedHandles ?? []) rememberObservedHandle(observedHandles, contactLookup, handle);
|
|
3952
|
+
|
|
3953
|
+
let previousSnapshot = new Map();
|
|
3954
|
+
let syncTimer = null;
|
|
3955
|
+
let contactsWatcher = null;
|
|
3956
|
+
let fallbackInterval = null;
|
|
3957
|
+
let syncing = false;
|
|
3958
|
+
let pendingReason = null;
|
|
3959
|
+
let stopped = false;
|
|
3960
|
+
|
|
3961
|
+
const visibleMappings = () => {
|
|
3962
|
+
const mappings = contactLookup.mappings();
|
|
3963
|
+
if (syncAllContacts) return mappings;
|
|
3964
|
+
return mappings.filter((mapping) => handleCandidates(mapping.handle).some((candidate) => observedHandles.has(candidate)));
|
|
3965
|
+
};
|
|
3966
|
+
|
|
3967
|
+
const syncNow = async ({ forceAll = false, reason = "manual" } = {}) => {
|
|
3968
|
+
if (stopped) return { skipped: true, reason: "stopped" };
|
|
3969
|
+
if (syncing) {
|
|
3970
|
+
pendingReason = reason;
|
|
3971
|
+
return { skipped: true, reason: "already_syncing" };
|
|
3972
|
+
}
|
|
3973
|
+
|
|
3974
|
+
syncing = true;
|
|
3975
|
+
try {
|
|
3976
|
+
const nextLookup = buildContactLookup();
|
|
3977
|
+
contactLookup.replace(nextLookup);
|
|
3978
|
+
const mappings = visibleMappings();
|
|
3979
|
+
const toSync = forceAll
|
|
3980
|
+
? mappings
|
|
3981
|
+
: mappings.filter((mapping) => previousSnapshot.get(mapping.handle) !== mapping.name);
|
|
3982
|
+
|
|
3983
|
+
if (toSync.length === 0) {
|
|
3984
|
+
previousSnapshot = snapshotContactMappings(mappings);
|
|
3985
|
+
return { updated: 0, contactsProcessed: 0 };
|
|
3986
|
+
}
|
|
3987
|
+
|
|
3988
|
+
const result = await sender.syncContacts(toSync);
|
|
3989
|
+
previousSnapshot = snapshotContactMappings(mappings);
|
|
3990
|
+
console.log(`[contacts] Synced ${toSync.length}/${mappings.length} Messages contact mappings (${reason}); updated ${result.updated ?? 0}`);
|
|
3991
|
+
return result;
|
|
3992
|
+
} finally {
|
|
3993
|
+
syncing = false;
|
|
3994
|
+
const queuedReason = pendingReason;
|
|
3995
|
+
pendingReason = null;
|
|
3996
|
+
if (queuedReason && !stopped) scheduleSync(queuedReason);
|
|
3997
|
+
}
|
|
3998
|
+
};
|
|
3999
|
+
|
|
4000
|
+
const scheduleSync = (reason) => {
|
|
4001
|
+
if (stopped) return;
|
|
4002
|
+
if (syncTimer) clearTimeout(syncTimer);
|
|
4003
|
+
syncTimer = setTimeout(() => {
|
|
4004
|
+
syncTimer = null;
|
|
4005
|
+
syncNow({ reason }).catch((err) => console.error("Messages contact sync failed:", safeError(err)));
|
|
4006
|
+
}, CONTACT_SYNC_DEBOUNCE_MS);
|
|
4007
|
+
};
|
|
4008
|
+
|
|
4009
|
+
const observeMessages = (messages) => {
|
|
4010
|
+
let changed = false;
|
|
4011
|
+
for (const msg of messages ?? []) {
|
|
4012
|
+
if (rememberObservedHandle(observedHandles, contactLookup, msg?.participant)) changed = true;
|
|
4013
|
+
if (msg?.affectedParticipant) {
|
|
4014
|
+
if (rememberObservedHandle(observedHandles, contactLookup, msg.affectedParticipant)) changed = true;
|
|
4015
|
+
}
|
|
4016
|
+
}
|
|
4017
|
+
if (changed) scheduleSync("observed-message-handle");
|
|
4018
|
+
};
|
|
4019
|
+
|
|
4020
|
+
const contactWatchPaths = addressBookWalPaths();
|
|
4021
|
+
if (contactWatchPaths.length > 0) {
|
|
4022
|
+
contactsWatcher = [];
|
|
4023
|
+
for (const path of contactWatchPaths) {
|
|
4024
|
+
try {
|
|
4025
|
+
contactsWatcher.push(watch(path, () => scheduleSync("contacts-watch")));
|
|
4026
|
+
} catch (err) {
|
|
4027
|
+
console.warn(`Could not watch Contacts WAL ${path}: ${safeError(err)}`);
|
|
4028
|
+
}
|
|
4029
|
+
}
|
|
4030
|
+
if (contactsWatcher.length > 0) {
|
|
4031
|
+
console.log(`Watching ${contactsWatcher.length} Contacts database WAL file(s) for Messages name changes`);
|
|
4032
|
+
} else {
|
|
4033
|
+
console.warn("Contacts WAL files were found but could not be watched; Messages contact sync will use fallback polling only");
|
|
4034
|
+
}
|
|
4035
|
+
} else if (platform() === "darwin") {
|
|
4036
|
+
console.warn("Contacts WAL not found; Messages contact sync will use fallback polling only");
|
|
4037
|
+
}
|
|
4038
|
+
|
|
4039
|
+
fallbackInterval = setInterval(() => {
|
|
4040
|
+
syncNow({ reason: "fallback" }).catch((err) => console.error("Messages contact fallback sync failed:", safeError(err)));
|
|
4041
|
+
}, CONTACT_SYNC_FALLBACK_MS);
|
|
4042
|
+
|
|
4043
|
+
return {
|
|
4044
|
+
observeMessages,
|
|
4045
|
+
syncNow,
|
|
4046
|
+
stop() {
|
|
4047
|
+
stopped = true;
|
|
4048
|
+
if (syncTimer) clearTimeout(syncTimer);
|
|
4049
|
+
if (fallbackInterval) clearInterval(fallbackInterval);
|
|
4050
|
+
if (contactsWatcher) {
|
|
4051
|
+
for (const watcher of contactsWatcher) watcher.close();
|
|
4052
|
+
}
|
|
4053
|
+
},
|
|
4054
|
+
};
|
|
4055
|
+
}
|
|
4056
|
+
|
|
4057
|
+
function rememberObservedHandle(observedHandles, contactLookup, handle) {
|
|
4058
|
+
const raw = typeof handle === "string" ? handle.trim() : "";
|
|
4059
|
+
if (!raw || contactLookup.isSelfHandle(raw)) return false;
|
|
4060
|
+
let changed = false;
|
|
4061
|
+
for (const candidate of handleCandidates(raw)) {
|
|
4062
|
+
if (!observedHandles.has(candidate)) {
|
|
4063
|
+
observedHandles.add(candidate);
|
|
4064
|
+
changed = true;
|
|
4065
|
+
}
|
|
4066
|
+
}
|
|
4067
|
+
return changed;
|
|
4068
|
+
}
|
|
4069
|
+
|
|
4070
|
+
function contactMappingsFromLookup(lookup) {
|
|
4071
|
+
const mappings = typeof lookup?.mappings === "function" ? lookup.mappings() : lookup?.mappings;
|
|
4072
|
+
if (!Array.isArray(mappings)) return [];
|
|
4073
|
+
return mappings.filter((mapping) =>
|
|
4074
|
+
mapping
|
|
4075
|
+
&& typeof mapping.handle === "string"
|
|
4076
|
+
&& mapping.handle.trim()
|
|
4077
|
+
&& typeof mapping.name === "string"
|
|
4078
|
+
&& mapping.name.trim());
|
|
4079
|
+
}
|
|
4080
|
+
|
|
4081
|
+
function snapshotContactMappings(mappings) {
|
|
4082
|
+
return new Map(mappings.map((mapping) => [mapping.handle, mapping.name]));
|
|
4083
|
+
}
|
|
4084
|
+
|
|
2948
4085
|
function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
|
|
2949
4086
|
const chatNames = new Map();
|
|
2950
4087
|
const isImageAttachment = kit.isImageAttachment ?? (() => false);
|
|
@@ -3039,6 +4176,9 @@ function buildContactLookup(opts = {}) {
|
|
|
3039
4176
|
isSelfHandle(handle) {
|
|
3040
4177
|
return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
|
|
3041
4178
|
},
|
|
4179
|
+
mappings() {
|
|
4180
|
+
return [...handleToName.entries()].map(([handle, name]) => ({ handle, name }));
|
|
4181
|
+
},
|
|
3042
4182
|
};
|
|
3043
4183
|
}
|
|
3044
4184
|
|
|
@@ -3050,6 +4190,9 @@ function emptyContactLookup() {
|
|
|
3050
4190
|
isSelfHandle() {
|
|
3051
4191
|
return false;
|
|
3052
4192
|
},
|
|
4193
|
+
mappings() {
|
|
4194
|
+
return [];
|
|
4195
|
+
},
|
|
3053
4196
|
};
|
|
3054
4197
|
}
|
|
3055
4198
|
|
|
@@ -3146,6 +4289,15 @@ function addressBookDatabasePaths() {
|
|
|
3146
4289
|
}
|
|
3147
4290
|
}
|
|
3148
4291
|
|
|
4292
|
+
function addressBookWalPaths() {
|
|
4293
|
+
if (platform() !== "darwin") return [];
|
|
4294
|
+
const paths = new Set([CONTACTS_WAL_PATH]);
|
|
4295
|
+
for (const dbPath of addressBookDatabasePaths()) {
|
|
4296
|
+
paths.add(`${dbPath}-wal`);
|
|
4297
|
+
}
|
|
4298
|
+
return [...paths].filter((path) => existsSync(path));
|
|
4299
|
+
}
|
|
4300
|
+
|
|
3149
4301
|
function loadMyCard() {
|
|
3150
4302
|
if (platform() !== "darwin") return null;
|
|
3151
4303
|
const script = `
|
|
@@ -3260,6 +4412,40 @@ function parseDmHandleFromChatId(chatId) {
|
|
|
3260
4412
|
return null;
|
|
3261
4413
|
}
|
|
3262
4414
|
|
|
4415
|
+
function messageChatTouchesShepherdAgent(chat) {
|
|
4416
|
+
if (isShepherdAgentMessageHandle(parseDmHandleFromChatId(chat?.chatId))) return true;
|
|
4417
|
+
return Array.isArray(chat?.participants)
|
|
4418
|
+
&& chat.participants.some((participant) => isShepherdAgentMessageHandle(participant?.handle));
|
|
4419
|
+
}
|
|
4420
|
+
|
|
4421
|
+
function messageTouchesShepherdAgent(msg) {
|
|
4422
|
+
return isShepherdAgentMessageHandle(msg?.participant)
|
|
4423
|
+
|| isShepherdAgentMessageHandle(msg?.affectedParticipant)
|
|
4424
|
+
|| isShepherdAgentMessageHandle(parseDmHandleFromChatId(msg?.chatId));
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
function isShepherdAgentMessageHandle(handle) {
|
|
4428
|
+
if (!SHEPHERD_OWNED_MESSAGE_HANDLES.length) return false;
|
|
4429
|
+
const blocked = new Set(SHEPHERD_OWNED_MESSAGE_HANDLES.flatMap(handleCandidates));
|
|
4430
|
+
return handleCandidates(handle).some((candidate) => blocked.has(candidate));
|
|
4431
|
+
}
|
|
4432
|
+
|
|
4433
|
+
function parseMessageHandleList(value) {
|
|
4434
|
+
return String(value ?? "")
|
|
4435
|
+
.split(",")
|
|
4436
|
+
.map((handle) => handle.trim())
|
|
4437
|
+
.filter(Boolean);
|
|
4438
|
+
}
|
|
4439
|
+
|
|
4440
|
+
function mergeShepherdOwnedMessageHandles(value) {
|
|
4441
|
+
for (const handle of Array.isArray(value) ? value : parseMessageHandleList(value)) {
|
|
4442
|
+
if (typeof handle !== "string" || !handle.trim()) continue;
|
|
4443
|
+
if (!SHEPHERD_OWNED_MESSAGE_HANDLES.includes(handle.trim())) {
|
|
4444
|
+
SHEPHERD_OWNED_MESSAGE_HANDLES.push(handle.trim());
|
|
4445
|
+
}
|
|
4446
|
+
}
|
|
4447
|
+
}
|
|
4448
|
+
|
|
3263
4449
|
function parseSelectionIndexes(answer, max) {
|
|
3264
4450
|
const indexes = new Set();
|
|
3265
4451
|
for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
|
|
@@ -3285,7 +4471,15 @@ function parseMessageChatIdsArg() {
|
|
|
3285
4471
|
function parseAllowedChatIds(value) {
|
|
3286
4472
|
if (!value) return [];
|
|
3287
4473
|
const raw = Array.isArray(value) ? value : String(value).split(",");
|
|
3288
|
-
|
|
4474
|
+
const values = raw.map((chatId) => String(chatId).trim()).filter(Boolean);
|
|
4475
|
+
if (values.some((chatId) => chatId.toLowerCase() === "all" || chatId === ALL_MESSAGES_CHATS)) {
|
|
4476
|
+
return [ALL_MESSAGES_CHATS];
|
|
4477
|
+
}
|
|
4478
|
+
return [...new Set(values)];
|
|
4479
|
+
}
|
|
4480
|
+
|
|
4481
|
+
function selectedChatIdsIncludeAll(chatIds) {
|
|
4482
|
+
return Array.isArray(chatIds) && chatIds.includes(ALL_MESSAGES_CHATS);
|
|
3289
4483
|
}
|
|
3290
4484
|
|
|
3291
4485
|
function html(value) {
|
|
@@ -3299,6 +4493,555 @@ function htmlAttr(value) {
|
|
|
3299
4493
|
return html(value).replace(/"/g, """);
|
|
3300
4494
|
}
|
|
3301
4495
|
|
|
4496
|
+
async function scanCodingSessions(config, previousState = { hashes: {} }) {
|
|
4497
|
+
const probes = await probeCodingSessionPaths(config);
|
|
4498
|
+
const errors = [];
|
|
4499
|
+
const sessions = [];
|
|
4500
|
+
const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
|
|
4501
|
+
const claudeDir = typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR;
|
|
4502
|
+
const maxFiles = Math.max(1, Math.floor(Number(config.maxFilesPerProvider ?? 300)));
|
|
4503
|
+
|
|
4504
|
+
for (const dir of codexDirs) {
|
|
4505
|
+
try {
|
|
4506
|
+
const files = await recentJsonlFiles(dir, maxFiles);
|
|
4507
|
+
for (const file of files) {
|
|
4508
|
+
const parsed = await parseCodexSessionFile(file, config).catch((err) => {
|
|
4509
|
+
errors.push({ provider: "codex", pathHash: hashString(file), error: safeError(err) });
|
|
4510
|
+
return null;
|
|
4511
|
+
});
|
|
4512
|
+
if (parsed) sessions.push(parsed);
|
|
4513
|
+
}
|
|
4514
|
+
} catch (err) {
|
|
4515
|
+
if (args.debug) errors.push({ provider: "codex", pathHash: hashString(dir), error: safeError(err) });
|
|
4516
|
+
}
|
|
4517
|
+
}
|
|
4518
|
+
|
|
4519
|
+
try {
|
|
4520
|
+
const files = await recentJsonlFiles(claudeDir, maxFiles);
|
|
4521
|
+
for (const file of files) {
|
|
4522
|
+
const parsed = await parseClaudeSessionFile(file, config).catch((err) => {
|
|
4523
|
+
errors.push({ provider: "claude", pathHash: hashString(file), error: safeError(err) });
|
|
4524
|
+
return null;
|
|
4525
|
+
});
|
|
4526
|
+
if (parsed) sessions.push(parsed);
|
|
4527
|
+
}
|
|
4528
|
+
} catch (err) {
|
|
4529
|
+
if (args.debug) errors.push({ provider: "claude", pathHash: hashString(claudeDir), error: safeError(err) });
|
|
4530
|
+
}
|
|
4531
|
+
|
|
4532
|
+
return {
|
|
4533
|
+
probes,
|
|
4534
|
+
errors,
|
|
4535
|
+
sessions: sessions
|
|
4536
|
+
.sort((a, b) => String(b.lastUpdatedAt ?? "").localeCompare(String(a.lastUpdatedAt ?? "")))
|
|
4537
|
+
.slice(0, maxFiles * 2),
|
|
4538
|
+
previousState,
|
|
4539
|
+
};
|
|
4540
|
+
}
|
|
4541
|
+
|
|
4542
|
+
async function probeCodingSessionPaths(config) {
|
|
4543
|
+
const probes = [];
|
|
4544
|
+
const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
|
|
4545
|
+
for (const path of codexDirs) probes.push(await probePath("codex", path));
|
|
4546
|
+
probes.push(await probePath("claude", typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR));
|
|
4547
|
+
return probes;
|
|
4548
|
+
}
|
|
4549
|
+
|
|
4550
|
+
async function probePath(provider, path) {
|
|
4551
|
+
try {
|
|
4552
|
+
await access(path, fsConstants.R_OK);
|
|
4553
|
+
const info = await stat(path);
|
|
4554
|
+
return { provider, path, readable: true, directory: info.isDirectory() };
|
|
4555
|
+
} catch (err) {
|
|
4556
|
+
return { provider, path, readable: false, reason: err?.code ?? safeError(err) };
|
|
4557
|
+
}
|
|
4558
|
+
}
|
|
4559
|
+
|
|
4560
|
+
async function recentJsonlFiles(root, limit) {
|
|
4561
|
+
const files = [];
|
|
4562
|
+
await walkJsonl(root, files, limit * 8);
|
|
4563
|
+
return files
|
|
4564
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
4565
|
+
.slice(0, limit)
|
|
4566
|
+
.map((file) => file.path);
|
|
4567
|
+
}
|
|
4568
|
+
|
|
4569
|
+
async function walkJsonl(dir, files, cap) {
|
|
4570
|
+
if (files.length >= cap) return;
|
|
4571
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
4572
|
+
for (const entry of entries) {
|
|
4573
|
+
if (files.length >= cap) return;
|
|
4574
|
+
const path = join(dir, entry.name);
|
|
4575
|
+
if (entry.isDirectory()) {
|
|
4576
|
+
await walkJsonl(path, files, cap).catch(() => undefined);
|
|
4577
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
4578
|
+
const info = await stat(path).catch(() => null);
|
|
4579
|
+
if (info) files.push({ path, mtimeMs: info.mtimeMs });
|
|
4580
|
+
}
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
|
|
4584
|
+
async function parseCodexSessionFile(path, config) {
|
|
4585
|
+
const lines = await readJsonlRecords(path);
|
|
4586
|
+
if (lines.length === 0) return null;
|
|
4587
|
+
const payloads = lines.map((line) => line.payload && typeof line.payload === "object" ? line.payload : line);
|
|
4588
|
+
const meta = payloads.find((payload) => payload && typeof payload === "object" && (payload.id || payload.started_at || payload.cwd)) ?? {};
|
|
4589
|
+
const timestamps = lines.map((line) => isoDate(line.timestamp ?? line.payload?.timestamp ?? line.payload?.started_at)).filter(Boolean);
|
|
4590
|
+
const cwd = firstString(payloads, ["cwd"]);
|
|
4591
|
+
const repo = await repoMetadata(cwd);
|
|
4592
|
+
const actor = await actorMetadata(cwd, config);
|
|
4593
|
+
const userTexts = extractTextValues(payloads, ["text", "query"]).filter((text) => text.length > 0);
|
|
4594
|
+
const summaries = extractTextValues(payloads, ["summary", "formatted_output"]).filter((text) => text.length > 0);
|
|
4595
|
+
const commands = extractCodexCommands(payloads);
|
|
4596
|
+
const session = {
|
|
4597
|
+
provider: "codex",
|
|
4598
|
+
sessionId: cleanSessionId(String(meta.id ?? basename(path, ".jsonl"))),
|
|
4599
|
+
startedAt: minIso(timestamps) ?? isoDate(meta.started_at),
|
|
4600
|
+
endedAt: maxIso(timestamps),
|
|
4601
|
+
lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
|
|
4602
|
+
status: sessionStatus(maxIso(timestamps)),
|
|
4603
|
+
actor,
|
|
4604
|
+
repo,
|
|
4605
|
+
cwdHash: cwd ? hashString(cwd) : null,
|
|
4606
|
+
cwdBasename: cwd ? basename(cwd) : null,
|
|
4607
|
+
title: redactText(firstUseful(userTexts) ?? firstUseful(summaries) ?? basename(path, ".jsonl"), 180),
|
|
4608
|
+
goal: redactText(firstUseful(userTexts), 600),
|
|
4609
|
+
summary: buildLocalSessionSummary("Codex", userTexts, summaries, commands),
|
|
4610
|
+
decisions: [],
|
|
4611
|
+
filesTouched: inferFilesTouched([...userTexts, ...summaries, ...commands.map((command) => command.command)]),
|
|
4612
|
+
commands,
|
|
4613
|
+
verification: inferVerification(commands),
|
|
4614
|
+
errors: inferErrors(commands, summaries),
|
|
4615
|
+
followUps: inferFollowUps([...userTexts, ...summaries]),
|
|
4616
|
+
omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
|
|
4617
|
+
sourcePathHash: hashString(path),
|
|
4618
|
+
};
|
|
4619
|
+
session.contentHash = hashObject(session);
|
|
4620
|
+
return session;
|
|
4621
|
+
}
|
|
4622
|
+
|
|
4623
|
+
async function parseClaudeSessionFile(path, config) {
|
|
4624
|
+
const lines = await readJsonlRecords(path);
|
|
4625
|
+
if (lines.length === 0) return null;
|
|
4626
|
+
const timestamps = lines.map((line) => isoDate(line.timestamp)).filter(Boolean);
|
|
4627
|
+
const cwd = firstString(lines, ["cwd"]);
|
|
4628
|
+
const repo = await repoMetadata(cwd);
|
|
4629
|
+
const actor = await actorMetadata(cwd, config);
|
|
4630
|
+
const sessionId = firstString(lines, ["sessionId"]) ?? basename(path, ".jsonl");
|
|
4631
|
+
const title = firstString(lines, ["aiTitle", "lastPrompt", "slug"]) ?? firstMessageText(lines);
|
|
4632
|
+
const userTexts = claudeTextValues(lines, "user");
|
|
4633
|
+
const assistantTexts = claudeTextValues(lines, "assistant");
|
|
4634
|
+
const commands = extractClaudeCommands(lines);
|
|
4635
|
+
const session = {
|
|
4636
|
+
provider: "claude",
|
|
4637
|
+
sessionId: cleanSessionId(sessionId),
|
|
4638
|
+
startedAt: minIso(timestamps),
|
|
4639
|
+
endedAt: maxIso(timestamps),
|
|
4640
|
+
lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
|
|
4641
|
+
status: sessionStatus(maxIso(timestamps)),
|
|
4642
|
+
actor,
|
|
4643
|
+
repo: {
|
|
4644
|
+
...repo,
|
|
4645
|
+
branch: repo.branch ?? firstString(lines, ["gitBranch"]),
|
|
4646
|
+
},
|
|
4647
|
+
cwdHash: cwd ? hashString(cwd) : null,
|
|
4648
|
+
cwdBasename: cwd ? basename(cwd) : null,
|
|
4649
|
+
title: redactText(title, 180),
|
|
4650
|
+
goal: redactText(firstUseful(userTexts), 600),
|
|
4651
|
+
summary: buildLocalSessionSummary("Claude Code", userTexts, assistantTexts, commands),
|
|
4652
|
+
decisions: [],
|
|
4653
|
+
filesTouched: inferFilesTouched([...userTexts, ...assistantTexts, ...commands.map((command) => command.command)]),
|
|
4654
|
+
commands,
|
|
4655
|
+
verification: inferVerification(commands),
|
|
4656
|
+
errors: inferErrors(commands, assistantTexts),
|
|
4657
|
+
followUps: inferFollowUps([...userTexts, ...assistantTexts]),
|
|
4658
|
+
omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
|
|
4659
|
+
sourcePathHash: hashString(path),
|
|
4660
|
+
};
|
|
4661
|
+
session.contentHash = hashObject(session);
|
|
4662
|
+
return session;
|
|
4663
|
+
}
|
|
4664
|
+
|
|
4665
|
+
async function readJsonlRecords(path) {
|
|
4666
|
+
const raw = await readFile(path, "utf8");
|
|
4667
|
+
return raw
|
|
4668
|
+
.split("\n")
|
|
4669
|
+
.filter((line) => line.trim())
|
|
4670
|
+
.slice(-3000)
|
|
4671
|
+
.flatMap((line) => {
|
|
4672
|
+
try {
|
|
4673
|
+
return [JSON.parse(line)];
|
|
4674
|
+
} catch {
|
|
4675
|
+
return [];
|
|
4676
|
+
}
|
|
4677
|
+
});
|
|
4678
|
+
}
|
|
4679
|
+
|
|
4680
|
+
function extractCodexCommands(payloads) {
|
|
4681
|
+
return payloads.flatMap((payload) => {
|
|
4682
|
+
if (!payload || typeof payload !== "object") return [];
|
|
4683
|
+
const command = payload.command ?? payload.parsed_cmd?.join?.(" ") ?? null;
|
|
4684
|
+
if (typeof command !== "string" || !command.trim()) return [];
|
|
4685
|
+
return [{
|
|
4686
|
+
command: redactText(command, 600),
|
|
4687
|
+
exitCode: typeof payload.exit_code === "number" ? payload.exit_code : null,
|
|
4688
|
+
summary: payload.stdout || payload.stderr
|
|
4689
|
+
? redactText(String(payload.stderr || payload.stdout).split("\n").find(Boolean) ?? "", 240)
|
|
4690
|
+
: null,
|
|
4691
|
+
}];
|
|
4692
|
+
}).slice(-100);
|
|
4693
|
+
}
|
|
4694
|
+
|
|
4695
|
+
function extractClaudeCommands(lines) {
|
|
4696
|
+
const commands = [];
|
|
4697
|
+
for (const line of lines) {
|
|
4698
|
+
for (const command of extractCommandStrings(line.toolUseResult ?? line.message?.content ?? line)) {
|
|
4699
|
+
if (isLikelyShellCommand(command)) {
|
|
4700
|
+
commands.push({ command: redactText(command, 600), exitCode: null, summary: null });
|
|
4701
|
+
}
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
return commands.slice(-100);
|
|
4705
|
+
}
|
|
4706
|
+
|
|
4707
|
+
function extractCommandStrings(value, depth = 0) {
|
|
4708
|
+
if (depth > 6 || value == null) return [];
|
|
4709
|
+
if (typeof value === "string") return [];
|
|
4710
|
+
if (Array.isArray(value)) return value.flatMap((item) => extractCommandStrings(item, depth + 1));
|
|
4711
|
+
if (typeof value !== "object") return [];
|
|
4712
|
+
|
|
4713
|
+
const commands = [];
|
|
4714
|
+
for (const [key, nested] of Object.entries(value)) {
|
|
4715
|
+
if ((key === "command" || key === "input") && typeof nested === "string" && nested.trim()) {
|
|
4716
|
+
commands.push(nested);
|
|
4717
|
+
continue;
|
|
4718
|
+
}
|
|
4719
|
+
commands.push(...extractCommandStrings(nested, depth + 1));
|
|
4720
|
+
}
|
|
4721
|
+
return commands;
|
|
4722
|
+
}
|
|
4723
|
+
|
|
4724
|
+
function isLikelyShellCommand(value) {
|
|
4725
|
+
return /\b(?:git|npm|pnpm|yarn|bun|pytest|vitest|cargo|go|python|node|tsc|ruff|eslint|make)\b/.test(value);
|
|
4726
|
+
}
|
|
4727
|
+
|
|
4728
|
+
async function repoMetadata(cwd) {
|
|
4729
|
+
const base = { fullName: null, remote: null, branch: null, commit: null };
|
|
4730
|
+
if (!cwd) return base;
|
|
4731
|
+
const remote = await gitValue(cwd, ["config", "--get", "remote.origin.url"]);
|
|
4732
|
+
const branch = await gitValue(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
4733
|
+
const commit = await gitValue(cwd, ["rev-parse", "--short", "HEAD"]);
|
|
4734
|
+
return {
|
|
4735
|
+
fullName: repoFullNameFromRemote(remote),
|
|
4736
|
+
remote: remote ? redactCredentialUrl(remote) : null,
|
|
4737
|
+
branch,
|
|
4738
|
+
commit,
|
|
4739
|
+
};
|
|
4740
|
+
}
|
|
4741
|
+
|
|
4742
|
+
async function actorMetadata(cwd, config) {
|
|
4743
|
+
const email = config.actorEmail ?? await gitValue(cwd, ["config", "user.email"]) ?? await gitValue(null, ["config", "--global", "user.email"]);
|
|
4744
|
+
const name = config.actorName ?? await gitValue(cwd, ["config", "user.name"]) ?? await gitValue(null, ["config", "--global", "user.name"]);
|
|
4745
|
+
return { email: email ?? null, name: name ?? null };
|
|
4746
|
+
}
|
|
4747
|
+
|
|
4748
|
+
async function gitValue(cwd, argv) {
|
|
4749
|
+
return new Promise((resolve) => {
|
|
4750
|
+
execFile("git", argv, { cwd: cwd || undefined, timeout: 2000, windowsHide: true }, (error, stdout) => {
|
|
4751
|
+
if (error) resolve(null);
|
|
4752
|
+
else resolve(String(stdout).trim() || null);
|
|
4753
|
+
});
|
|
4754
|
+
});
|
|
4755
|
+
}
|
|
4756
|
+
|
|
4757
|
+
function buildLocalSessionSummary(provider, userTexts, assistantTexts, commands) {
|
|
4758
|
+
const primary = firstUseful([...userTexts, ...assistantTexts]);
|
|
4759
|
+
const commandSummary = commands.length ? ` Commands included: ${commands.slice(-5).map((command) => command.command).join("; ")}.` : "";
|
|
4760
|
+
return redactText(`${provider} session${primary ? `: ${primary}` : " synced."}${commandSummary}`, 1200);
|
|
4761
|
+
}
|
|
4762
|
+
|
|
4763
|
+
function firstUseful(values) {
|
|
4764
|
+
return values.find((value) => value && value.trim().length > 8) ?? null;
|
|
4765
|
+
}
|
|
4766
|
+
|
|
4767
|
+
function firstString(records, keys) {
|
|
4768
|
+
for (const record of records) {
|
|
4769
|
+
if (!record || typeof record !== "object") continue;
|
|
4770
|
+
for (const key of keys) {
|
|
4771
|
+
const value = record[key];
|
|
4772
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
4773
|
+
}
|
|
4774
|
+
}
|
|
4775
|
+
return null;
|
|
4776
|
+
}
|
|
4777
|
+
|
|
4778
|
+
function extractTextValues(records, keys) {
|
|
4779
|
+
const values = [];
|
|
4780
|
+
for (const record of records) {
|
|
4781
|
+
if (!record || typeof record !== "object") continue;
|
|
4782
|
+
for (const key of keys) {
|
|
4783
|
+
const value = record[key];
|
|
4784
|
+
if (typeof value === "string" && value.trim()) values.push(redactText(value, 1200));
|
|
4785
|
+
}
|
|
4786
|
+
}
|
|
4787
|
+
return values;
|
|
4788
|
+
}
|
|
4789
|
+
|
|
4790
|
+
function claudeTextValues(records, role) {
|
|
4791
|
+
const values = [];
|
|
4792
|
+
for (const record of records) {
|
|
4793
|
+
const message = record?.message;
|
|
4794
|
+
if (!message || typeof message !== "object" || message.role !== role) continue;
|
|
4795
|
+
const content = message.content;
|
|
4796
|
+
if (typeof content === "string") values.push(redactText(content, 1200));
|
|
4797
|
+
if (Array.isArray(content)) {
|
|
4798
|
+
for (const item of content) {
|
|
4799
|
+
if (item?.type === "text" && typeof item.text === "string") values.push(redactText(item.text, 1200));
|
|
4800
|
+
}
|
|
4801
|
+
}
|
|
4802
|
+
}
|
|
4803
|
+
return values;
|
|
4804
|
+
}
|
|
4805
|
+
|
|
4806
|
+
function firstMessageText(records) {
|
|
4807
|
+
return firstUseful(claudeTextValues(records, "user")) ?? firstUseful(claudeTextValues(records, "assistant"));
|
|
4808
|
+
}
|
|
4809
|
+
|
|
4810
|
+
function inferFilesTouched(values) {
|
|
4811
|
+
const matches = new Set();
|
|
4812
|
+
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;
|
|
4813
|
+
for (const value of values) {
|
|
4814
|
+
for (const match of String(value ?? "").matchAll(fileRe)) matches.add(match[0]);
|
|
4815
|
+
}
|
|
4816
|
+
return [...matches].slice(0, 200);
|
|
4817
|
+
}
|
|
4818
|
+
|
|
4819
|
+
function inferVerification(commands) {
|
|
4820
|
+
return commands
|
|
4821
|
+
.filter((command) => /\b(?:test|vitest|jest|pytest|tsc|lint|eslint|build|cargo test|go test)\b/i.test(command.command))
|
|
4822
|
+
.map((command) => `${command.command}${command.exitCode == null ? "" : ` exited ${command.exitCode}`}`)
|
|
4823
|
+
.slice(0, 30);
|
|
4824
|
+
}
|
|
4825
|
+
|
|
4826
|
+
function inferErrors(commands, texts) {
|
|
4827
|
+
const errors = [];
|
|
4828
|
+
for (const command of commands) {
|
|
4829
|
+
if (typeof command.exitCode === "number" && command.exitCode !== 0) errors.push(`${command.command} exited ${command.exitCode}`);
|
|
4830
|
+
}
|
|
4831
|
+
for (const text of texts) {
|
|
4832
|
+
if (/\b(?:error|failed|failing|blocked|exception)\b/i.test(text)) errors.push(redactText(text, 280));
|
|
4833
|
+
}
|
|
4834
|
+
return [...new Set(errors)].slice(0, 30);
|
|
4835
|
+
}
|
|
4836
|
+
|
|
4837
|
+
function inferFollowUps(texts) {
|
|
4838
|
+
return texts
|
|
4839
|
+
.filter((text) => /\b(?:todo|follow up|next|later|needs?|remaining)\b/i.test(text))
|
|
4840
|
+
.map((text) => redactText(text, 280))
|
|
4841
|
+
.slice(0, 30);
|
|
4842
|
+
}
|
|
4843
|
+
|
|
4844
|
+
function sessionStatus(lastUpdatedAt) {
|
|
4845
|
+
if (!lastUpdatedAt) return "unknown";
|
|
4846
|
+
return Date.now() - Date.parse(lastUpdatedAt) < 5 * 60_000 ? "active" : "settled";
|
|
4847
|
+
}
|
|
4848
|
+
|
|
4849
|
+
function minIso(values) {
|
|
4850
|
+
return values.length ? values.sort()[0] : null;
|
|
4851
|
+
}
|
|
4852
|
+
|
|
4853
|
+
function maxIso(values) {
|
|
4854
|
+
return values.length ? values.sort().at(-1) : null;
|
|
4855
|
+
}
|
|
4856
|
+
|
|
4857
|
+
function cleanSessionId(value) {
|
|
4858
|
+
return String(value ?? "").replace(/[^a-zA-Z0-9_.:-]/g, "-").slice(0, 200) || hashString(value).slice(0, 16);
|
|
4859
|
+
}
|
|
4860
|
+
|
|
4861
|
+
function repoFullNameFromRemote(remote) {
|
|
4862
|
+
if (!remote) return null;
|
|
4863
|
+
const match = String(remote).match(/[:/]([^/:]+\/[^/.]+)(?:\.git)?$/);
|
|
4864
|
+
return match?.[1] ?? null;
|
|
4865
|
+
}
|
|
4866
|
+
|
|
4867
|
+
function redactCredentialUrl(value) {
|
|
4868
|
+
return String(value).replace(/:\/\/[^/@]+@/g, "://[redacted]@");
|
|
4869
|
+
}
|
|
4870
|
+
|
|
4871
|
+
function redactText(value, maxLength = 1000) {
|
|
4872
|
+
return String(value ?? "")
|
|
4873
|
+
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g, "[redacted-private-key]")
|
|
4874
|
+
.replace(/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs])_[A-Za-z0-9_=-]{12,}\b/g, "[redacted-token]")
|
|
4875
|
+
.replace(/\b[A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@/g, "[redacted-credentials]@")
|
|
4876
|
+
.replace(/\b(authorization|x-api-key|api[_-]?key|token|secret|password)(\s*[:=]\s*)['"]?[^'"\s]+/gi, "$1$2[redacted]")
|
|
4877
|
+
.replace(/(OPENAI_API_KEY|FIREWORKS_API_KEY|ANTHROPIC_API_KEY|DATABASE_URL|REDIS_URL)=\S+/g, "$1=[redacted]")
|
|
4878
|
+
.replace(new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "~")
|
|
4879
|
+
.slice(0, maxLength);
|
|
4880
|
+
}
|
|
4881
|
+
|
|
4882
|
+
function hashString(value) {
|
|
4883
|
+
return createHash("sha256").update(String(value ?? "")).digest("hex");
|
|
4884
|
+
}
|
|
4885
|
+
|
|
4886
|
+
function hashObject(value) {
|
|
4887
|
+
return hashString(JSON.stringify(value));
|
|
4888
|
+
}
|
|
4889
|
+
|
|
4890
|
+
function readCodingSessionsState(path) {
|
|
4891
|
+
const value = readJsonOptional(path);
|
|
4892
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
4893
|
+
? { hashes: value.hashes && typeof value.hashes === "object" ? value.hashes : {} }
|
|
4894
|
+
: { hashes: {} };
|
|
4895
|
+
}
|
|
4896
|
+
|
|
4897
|
+
function codingSessionsStateFile(userId) {
|
|
4898
|
+
const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-state.json`);
|
|
4899
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4900
|
+
return path;
|
|
4901
|
+
}
|
|
4902
|
+
|
|
4903
|
+
function codingSessionsStatusFile(userId) {
|
|
4904
|
+
const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-status.json`);
|
|
4905
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4906
|
+
return path;
|
|
4907
|
+
}
|
|
4908
|
+
|
|
4909
|
+
async function messagesConfigPathForUser(userId) {
|
|
4910
|
+
if (!userId) return null;
|
|
4911
|
+
const path = join(homedir(), ".shepherd", "raw-messages", `${userId}.json`);
|
|
4912
|
+
return existsSync(path) ? path : null;
|
|
4913
|
+
}
|
|
4914
|
+
|
|
4915
|
+
async function codingSessionsConfigPathForUser(userId) {
|
|
4916
|
+
if (!userId) return null;
|
|
4917
|
+
const path = join(homedir(), ".shepherd", "coding-sessions", `${userId}.json`);
|
|
4918
|
+
return existsSync(path) ? path : null;
|
|
4919
|
+
}
|
|
4920
|
+
|
|
4921
|
+
async function latestMessagesConfigPath() {
|
|
4922
|
+
const dir = join(homedir(), ".shepherd", "raw-messages");
|
|
4923
|
+
try {
|
|
4924
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
4925
|
+
const files = [];
|
|
4926
|
+
for (const entry of entries) {
|
|
4927
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.includes("-queue")) continue;
|
|
4928
|
+
const path = join(dir, entry.name);
|
|
4929
|
+
const info = await stat(path).catch(() => null);
|
|
4930
|
+
if (info) files.push({ path, mtimeMs: info.mtimeMs });
|
|
4931
|
+
}
|
|
4932
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path ?? null;
|
|
4933
|
+
} catch {
|
|
4934
|
+
return null;
|
|
4935
|
+
}
|
|
4936
|
+
}
|
|
4937
|
+
|
|
4938
|
+
async function latestCodingSessionsConfigPath() {
|
|
4939
|
+
const dir = join(homedir(), ".shepherd", "coding-sessions");
|
|
4940
|
+
try {
|
|
4941
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
4942
|
+
const files = [];
|
|
4943
|
+
for (const entry of entries) {
|
|
4944
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.includes("-state") || entry.name.includes("-status") || entry.name.includes("-queue")) continue;
|
|
4945
|
+
const path = join(dir, entry.name);
|
|
4946
|
+
const info = await stat(path).catch(() => null);
|
|
4947
|
+
if (info) files.push({ path, mtimeMs: info.mtimeMs });
|
|
4948
|
+
}
|
|
4949
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path ?? null;
|
|
4950
|
+
} catch {
|
|
4951
|
+
return null;
|
|
4952
|
+
}
|
|
4953
|
+
}
|
|
4954
|
+
|
|
4955
|
+
async function productionOnboardingStatusForCodingSessions(config) {
|
|
4956
|
+
if (!config?.userId || !config?.apiUrl) return null;
|
|
4957
|
+
const local = await readOptionalAgentState().catch(() => null);
|
|
4958
|
+
if (!local?.sessionToken || local.sessionId !== config.userId) return null;
|
|
4959
|
+
return getJson(
|
|
4960
|
+
`${trimTrailingSlash(config.apiUrl)}/onboarding/raw/session/${encodeURIComponent(config.userId)}/status`,
|
|
4961
|
+
{ token: local.sessionToken },
|
|
4962
|
+
);
|
|
4963
|
+
}
|
|
4964
|
+
|
|
4965
|
+
function readJsonOptional(path) {
|
|
4966
|
+
try {
|
|
4967
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
4968
|
+
} catch {
|
|
4969
|
+
return null;
|
|
4970
|
+
}
|
|
4971
|
+
}
|
|
4972
|
+
|
|
4973
|
+
class CodingSessionsBatchSender {
|
|
4974
|
+
constructor(apiUrl, agentToken, userId) {
|
|
4975
|
+
this.apiUrl = trimTrailingSlash(apiUrl);
|
|
4976
|
+
this.agentToken = agentToken;
|
|
4977
|
+
this.userId = userId;
|
|
4978
|
+
this.queueFile = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-queue.json`);
|
|
4979
|
+
}
|
|
4980
|
+
|
|
4981
|
+
async send(sessions) {
|
|
4982
|
+
const queued = this.loadQueue();
|
|
4983
|
+
const all = [...queued, ...sessions];
|
|
4984
|
+
if (!all.length) return { stored: 0, updated: 0, skipped: 0 };
|
|
4985
|
+
|
|
4986
|
+
let totalStored = 0;
|
|
4987
|
+
let totalUpdated = 0;
|
|
4988
|
+
let totalSkipped = 0;
|
|
4989
|
+
|
|
4990
|
+
for (let i = 0; i < all.length; i += MAX_BATCH_SIZE) {
|
|
4991
|
+
const batch = all.slice(i, i + MAX_BATCH_SIZE);
|
|
4992
|
+
try {
|
|
4993
|
+
const result = await this.postBatch(batch);
|
|
4994
|
+
totalStored += result.stored ?? 0;
|
|
4995
|
+
totalUpdated += result.updated ?? 0;
|
|
4996
|
+
totalSkipped += result.skipped ?? 0;
|
|
4997
|
+
} catch (err) {
|
|
4998
|
+
this.saveQueue(all.slice(i));
|
|
4999
|
+
console.error("Coding sessions batch send failed:", safeError(err));
|
|
5000
|
+
return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
|
|
5001
|
+
}
|
|
5002
|
+
}
|
|
5003
|
+
|
|
5004
|
+
this.clearQueue();
|
|
5005
|
+
return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
|
|
5006
|
+
}
|
|
5007
|
+
|
|
5008
|
+
async postBatch(sessions) {
|
|
5009
|
+
const res = await fetch(`${this.apiUrl}/api/coding-sessions/ingest`, {
|
|
5010
|
+
method: "POST",
|
|
5011
|
+
headers: {
|
|
5012
|
+
"Content-Type": "application/json",
|
|
5013
|
+
"x-api-key": this.agentToken,
|
|
5014
|
+
},
|
|
5015
|
+
body: JSON.stringify({ userId: this.userId, sessions }),
|
|
5016
|
+
});
|
|
5017
|
+
|
|
5018
|
+
const json = await res.json().catch(() => ({}));
|
|
5019
|
+
if (!res.ok) throw new Error(json.error ?? `Coding sessions ingest failed (${res.status})`);
|
|
5020
|
+
return json;
|
|
5021
|
+
}
|
|
5022
|
+
|
|
5023
|
+
loadQueue() {
|
|
5024
|
+
try {
|
|
5025
|
+
return JSON.parse(readFileSync(this.queueFile, "utf8"));
|
|
5026
|
+
} catch {
|
|
5027
|
+
return [];
|
|
5028
|
+
}
|
|
5029
|
+
}
|
|
5030
|
+
|
|
5031
|
+
saveQueue(sessions) {
|
|
5032
|
+
mkdirSync(dirname(this.queueFile), { recursive: true });
|
|
5033
|
+
writeFileSync(this.queueFile, JSON.stringify(sessions.slice(-MAX_QUEUE_MESSAGES)), { mode: 0o600 });
|
|
5034
|
+
}
|
|
5035
|
+
|
|
5036
|
+
clearQueue() {
|
|
5037
|
+
try {
|
|
5038
|
+
unlinkSync(this.queueFile);
|
|
5039
|
+
} catch {
|
|
5040
|
+
// Queue is already empty.
|
|
5041
|
+
}
|
|
5042
|
+
}
|
|
5043
|
+
}
|
|
5044
|
+
|
|
3302
5045
|
class MessagesBatchSender {
|
|
3303
5046
|
constructor(apiUrl, agentToken, userId) {
|
|
3304
5047
|
this.apiUrl = trimTrailingSlash(apiUrl);
|
|
@@ -3347,6 +5090,21 @@ class MessagesBatchSender {
|
|
|
3347
5090
|
return json;
|
|
3348
5091
|
}
|
|
3349
5092
|
|
|
5093
|
+
async syncContacts(contacts) {
|
|
5094
|
+
const res = await fetch(`${this.apiUrl}/api/imessage/ingest/sync-contacts`, {
|
|
5095
|
+
method: "POST",
|
|
5096
|
+
headers: {
|
|
5097
|
+
"Content-Type": "application/json",
|
|
5098
|
+
"x-api-key": this.agentToken,
|
|
5099
|
+
},
|
|
5100
|
+
body: JSON.stringify({ userId: this.userId, contacts }),
|
|
5101
|
+
});
|
|
5102
|
+
|
|
5103
|
+
const json = await res.json().catch(() => ({}));
|
|
5104
|
+
if (!res.ok) throw new Error(json.error ?? `Messages contact sync failed (${res.status})`);
|
|
5105
|
+
return json;
|
|
5106
|
+
}
|
|
5107
|
+
|
|
3350
5108
|
loadQueue() {
|
|
3351
5109
|
try {
|
|
3352
5110
|
return JSON.parse(readFileSync(this.queueFile, "utf8"));
|