amicus 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/CHANGELOG.md +46 -0
  2. package/LICENSE +21 -0
  3. package/README.md +477 -0
  4. package/bin/amicus.js +382 -0
  5. package/electron/assets/icon.png +0 -0
  6. package/electron/assets/icon.svg +5 -0
  7. package/electron/fold.js +163 -0
  8. package/electron/ipc-setup.js +176 -0
  9. package/electron/load-failsafe.js +85 -0
  10. package/electron/main.js +468 -0
  11. package/electron/preload-setup.js +38 -0
  12. package/electron/preload.js +33 -0
  13. package/electron/setup-ui-alias-script.js +218 -0
  14. package/electron/setup-ui-aliases.js +85 -0
  15. package/electron/setup-ui-keys-script.js +115 -0
  16. package/electron/setup-ui-keys.js +97 -0
  17. package/electron/setup-ui-model.js +138 -0
  18. package/electron/setup-ui-styles.js +327 -0
  19. package/electron/setup-ui.js +465 -0
  20. package/electron/summary.js +118 -0
  21. package/electron/toolbar.js +229 -0
  22. package/electron/window-position.js +35 -0
  23. package/package.json +98 -0
  24. package/scripts/postinstall.js +193 -0
  25. package/scripts/setup-hooks.js +42 -0
  26. package/skill/SKILL.md +976 -0
  27. package/skills/second-opinion/COUNCIL-DESIGN.md +227 -0
  28. package/skills/second-opinion/MODEL-NOTES.md +104 -0
  29. package/skills/second-opinion/SKILL.md +389 -0
  30. package/src/cli-handlers.js +188 -0
  31. package/src/cli.js +400 -0
  32. package/src/conflict.js +144 -0
  33. package/src/context-compression.js +102 -0
  34. package/src/context.js +199 -0
  35. package/src/drift.js +144 -0
  36. package/src/environment.js +157 -0
  37. package/src/headless.js +742 -0
  38. package/src/index.js +106 -0
  39. package/src/jsonl-parser.js +180 -0
  40. package/src/mcp-server.js +625 -0
  41. package/src/mcp-tools.js +407 -0
  42. package/src/opencode-client.js +615 -0
  43. package/src/prompt-builder.js +355 -0
  44. package/src/prompts/cowork-agent-prompt.js +118 -0
  45. package/src/session-manager.js +414 -0
  46. package/src/session.js +180 -0
  47. package/src/sidecar/context-builder.js +297 -0
  48. package/src/sidecar/continue.js +212 -0
  49. package/src/sidecar/crash-handler.js +56 -0
  50. package/src/sidecar/fanout-leg.js +107 -0
  51. package/src/sidecar/fanout-output.js +46 -0
  52. package/src/sidecar/fanout.js +236 -0
  53. package/src/sidecar/interactive.js +217 -0
  54. package/src/sidecar/models.js +135 -0
  55. package/src/sidecar/progress.js +218 -0
  56. package/src/sidecar/read.js +183 -0
  57. package/src/sidecar/resume.js +221 -0
  58. package/src/sidecar/session-utils.js +288 -0
  59. package/src/sidecar/setup-window.js +79 -0
  60. package/src/sidecar/setup.js +280 -0
  61. package/src/sidecar/start.js +251 -0
  62. package/src/utils/agent-mapping.js +138 -0
  63. package/src/utils/alias-audit.js +98 -0
  64. package/src/utils/alias-resolver.js +77 -0
  65. package/src/utils/api-key-store.js +259 -0
  66. package/src/utils/api-key-validation.js +97 -0
  67. package/src/utils/auth-json.js +109 -0
  68. package/src/utils/config.js +291 -0
  69. package/src/utils/curated-models.js +82 -0
  70. package/src/utils/env-compat.js +38 -0
  71. package/src/utils/env-loader.js +54 -0
  72. package/src/utils/idle-watchdog.js +225 -0
  73. package/src/utils/input-validators.js +127 -0
  74. package/src/utils/lifecycle.js +43 -0
  75. package/src/utils/logger.js +84 -0
  76. package/src/utils/mcp-discovery.js +194 -0
  77. package/src/utils/mcp-validators.js +78 -0
  78. package/src/utils/model-catalog.js +103 -0
  79. package/src/utils/model-fetcher.js +179 -0
  80. package/src/utils/model-validator.js +207 -0
  81. package/src/utils/path-setup.js +41 -0
  82. package/src/utils/port-pid.js +39 -0
  83. package/src/utils/prompt-source.js +53 -0
  84. package/src/utils/result-schema.js +261 -0
  85. package/src/utils/server-setup.js +93 -0
  86. package/src/utils/session-abort.js +53 -0
  87. package/src/utils/session-lock.js +95 -0
  88. package/src/utils/shared-server.js +216 -0
  89. package/src/utils/start-helpers.js +76 -0
  90. package/src/utils/thinking-validators.js +92 -0
  91. package/src/utils/update-notifier-loader.js +18 -0
  92. package/src/utils/updater.js +157 -0
  93. package/src/utils/validators.js +300 -0
package/src/context.js ADDED
@@ -0,0 +1,199 @@
1
+ /**
2
+ * Context Filtering Module
3
+ *
4
+ * Spec Reference: Section 5.3 Context Filtering Algorithm
5
+ * Extracts and filters context from Claude Code sessions for sidecar use.
6
+ */
7
+
8
+ const { readJSONL, formatContext } = require('./jsonl-parser');
9
+
10
+ /**
11
+ * Parse a duration string into milliseconds
12
+ * Spec Reference: §4.1 --context-since option
13
+ *
14
+ * @param {string} durationStr - Duration string (e.g., "30m", "2h", "1d")
15
+ * @returns {number} Duration in milliseconds, or 0 if invalid
16
+ *
17
+ * @example
18
+ * parseDuration('30m') // Returns: 1800000 (30 * 60 * 1000)
19
+ * parseDuration('2h') // Returns: 7200000 (2 * 60 * 60 * 1000)
20
+ * parseDuration('1d') // Returns: 86400000 (24 * 60 * 60 * 1000)
21
+ */
22
+ function parseDuration(durationStr) {
23
+ if (!durationStr || typeof durationStr !== 'string') {
24
+ return 0;
25
+ }
26
+
27
+ const match = durationStr.match(/^(\d+)([mhd])$/);
28
+ if (!match) {
29
+ return 0;
30
+ }
31
+
32
+ const value = parseInt(match[1], 10);
33
+ const unit = match[2];
34
+
35
+ const multipliers = {
36
+ m: 60 * 1000, // minutes to ms
37
+ h: 60 * 60 * 1000, // hours to ms
38
+ d: 24 * 60 * 60 * 1000 // days to ms
39
+ };
40
+
41
+ return value * multipliers[unit];
42
+ }
43
+
44
+ /**
45
+ * Estimate token count from text
46
+ * Spec Reference: §5.3 "~4 chars per token"
47
+ *
48
+ * @param {string} text - Text to estimate tokens for
49
+ * @returns {number} Estimated token count
50
+ */
51
+ function estimateTokens(text) {
52
+ if (!text || typeof text !== 'string') {
53
+ return 0;
54
+ }
55
+ // Spec specifies ~4 chars per token
56
+ return Math.floor(text.length / 4);
57
+ }
58
+
59
+ /**
60
+ * Extract the last N user turns from messages
61
+ * A "turn" is defined as a user message plus all subsequent messages until the next user message.
62
+ *
63
+ * @param {object[]} messages - Array of message objects
64
+ * @param {number} n - Number of turns to keep
65
+ * @returns {object[]} Messages from the last N turns
66
+ *
67
+ * @example
68
+ * // Given messages: [user1, assistant1, user2, assistant2, user3, assistant3]
69
+ * // takeLastNTurns(messages, 2) returns: [user2, assistant2, user3, assistant3]
70
+ */
71
+ function takeLastNTurns(messages, n) {
72
+ if (!messages || messages.length === 0 || n <= 0) {
73
+ return [];
74
+ }
75
+
76
+ // Find indices of all user messages (turns)
77
+ const userIndices = [];
78
+ for (let i = 0; i < messages.length; i++) {
79
+ if (messages[i].type === 'user') {
80
+ userIndices.push(i);
81
+ }
82
+ }
83
+
84
+ // If we have fewer turns than requested, return all messages
85
+ if (userIndices.length <= n) {
86
+ return messages;
87
+ }
88
+
89
+ // Get the starting index for the last N turns
90
+ const startTurnIndex = userIndices.length - n;
91
+ const startIdx = userIndices[startTurnIndex];
92
+
93
+ // Return all messages from that point
94
+ return messages.slice(startIdx);
95
+ }
96
+
97
+ /**
98
+ * Filter messages by time
99
+ *
100
+ * @param {object[]} messages - Array of message objects
101
+ * @param {number} sinceMs - Duration in milliseconds
102
+ * @returns {object[]} Messages within the time window
103
+ */
104
+ function filterByTime(messages, sinceMs) {
105
+ const cutoff = Date.now() - sinceMs;
106
+
107
+ return messages.filter(msg => {
108
+ if (!msg.timestamp) {
109
+ return false;
110
+ }
111
+ const msgTime = new Date(msg.timestamp).getTime();
112
+ return msgTime >= cutoff;
113
+ });
114
+ }
115
+
116
+ /**
117
+ * Truncate context from start to fit within token limit
118
+ * Spec Reference: §5.3 "Truncate from start if over limit, prepend [Earlier context truncated...]"
119
+ *
120
+ * @param {string} context - Formatted context string
121
+ * @param {number} maxTokens - Maximum token limit
122
+ * @returns {string} Truncated context with notice if needed
123
+ */
124
+ function truncateToTokenLimit(context, maxTokens) {
125
+ const maxChars = maxTokens * 4; // ~4 chars per token
126
+
127
+ if (context.length <= maxChars) {
128
+ return context;
129
+ }
130
+
131
+ // Truncate from start, keeping the end (most recent)
132
+ const truncated = context.slice(-maxChars);
133
+
134
+ // Prepend truncation notice
135
+ return '[Earlier context truncated...]\n\n' + truncated;
136
+ }
137
+
138
+ /**
139
+ * Filter context from a Claude Code session
140
+ * Spec Reference: §5.3 Context Filtering Algorithm
141
+ *
142
+ * @param {string} sessionPath - Path to the session JSONL file
143
+ * @param {object} options - Filtering options
144
+ * @param {number} [options.turns=50] - Max conversation turns to include
145
+ * @param {string} [options.since] - Time filter (e.g., "2h"). Overrides turns if specified.
146
+ * @param {number} [options.maxTokens=80000] - Hard cap on context tokens
147
+ * @returns {string} Filtered and formatted context string
148
+ *
149
+ * @throws {Error} If session file cannot be read
150
+ *
151
+ * @example
152
+ * // Filter last 50 turns, max 80000 tokens
153
+ * filterContext('/path/to/session.jsonl', { turns: 50, maxTokens: 80000 })
154
+ *
155
+ * @example
156
+ * // Filter by time (last 2 hours), overrides turns
157
+ * filterContext('/path/to/session.jsonl', { since: '2h', turns: 50, maxTokens: 80000 })
158
+ */
159
+ function filterContext(sessionPath, options = {}) {
160
+ const {
161
+ turns = 50,
162
+ since,
163
+ maxTokens = 80000
164
+ } = options;
165
+
166
+ // Read and parse the session file
167
+ let messages = readJSONL(sessionPath);
168
+
169
+ if (messages.length === 0) {
170
+ return '';
171
+ }
172
+
173
+ // Apply filtering: time-based filter takes precedence over turns
174
+ if (since) {
175
+ const sinceMs = parseDuration(since);
176
+ if (sinceMs > 0) {
177
+ messages = filterByTime(messages, sinceMs);
178
+ }
179
+ } else if (turns) {
180
+ messages = takeLastNTurns(messages, turns);
181
+ }
182
+
183
+ // Format messages into context string
184
+ let context = formatContext(messages);
185
+
186
+ // Truncate to token limit if needed
187
+ if (estimateTokens(context) > maxTokens) {
188
+ context = truncateToTokenLimit(context, maxTokens);
189
+ }
190
+
191
+ return context;
192
+ }
193
+
194
+ module.exports = {
195
+ filterContext,
196
+ parseDuration,
197
+ estimateTokens,
198
+ takeLastNTurns
199
+ };
package/src/drift.js ADDED
@@ -0,0 +1,144 @@
1
+ /**
2
+ * Context Drift Detection Module
3
+ *
4
+ * Spec Reference: Section 7.3 Context Drift Indicator
5
+ * Detects and reports context staleness in sidecar sessions.
6
+ */
7
+
8
+ const fs = require('fs');
9
+ const { readJSONL } = require('./jsonl-parser');
10
+
11
+ /**
12
+ * Count user turns in a session since a given time
13
+ * A "turn" is defined as a user message.
14
+ *
15
+ * @param {string} sessionPath - Path to the session JSONL file
16
+ * @param {Date|string} sinceTime - Count turns after this time
17
+ * @returns {number} Number of user turns since the specified time
18
+ */
19
+ function countTurnsSince(sessionPath, sinceTime) {
20
+ // Handle non-existent file
21
+ if (!fs.existsSync(sessionPath)) {
22
+ return 0;
23
+ }
24
+
25
+ // Normalize time to Date
26
+ const since = sinceTime instanceof Date ? sinceTime : new Date(sinceTime);
27
+
28
+ let messages;
29
+ try {
30
+ messages = readJSONL(sessionPath);
31
+ } catch (error) {
32
+ return 0;
33
+ }
34
+
35
+ // Count user messages after sinceTime
36
+ let count = 0;
37
+ for (const msg of messages) {
38
+ if (msg.type !== 'user') {
39
+ continue;
40
+ }
41
+
42
+ // Skip messages without timestamps
43
+ if (!msg.timestamp) {
44
+ continue;
45
+ }
46
+
47
+ const msgTime = new Date(msg.timestamp);
48
+ if (msgTime > since) {
49
+ count++;
50
+ }
51
+ }
52
+
53
+ return count;
54
+ }
55
+
56
+ /**
57
+ * Check if drift is significant
58
+ * Spec Reference: §7.3 "Significant threshold: >10 min OR >5 turns"
59
+ *
60
+ * @param {number} ageMinutes - Age of the sidecar session in minutes
61
+ * @param {number} mainTurns - Number of turns in main session since sidecar started
62
+ * @returns {boolean} True if drift is significant
63
+ */
64
+ function isDriftSignificant(ageMinutes, mainTurns) {
65
+ // Spec §7.3: isSignificant: ageMinutes > 10 || mainTurns > 5
66
+ return ageMinutes > 10 || mainTurns > 5;
67
+ }
68
+
69
+ /**
70
+ * Calculate context drift for a sidecar session
71
+ * Spec Reference: §7.3 Context Drift Indicator
72
+ *
73
+ * @param {Date|string} sessionStartTime - When the sidecar session started
74
+ * @param {string} mainSessionPath - Path to the main Claude Code session JSONL
75
+ * @returns {object} Drift information
76
+ * @returns {number} returns.ageMinutes - Minutes since sidecar started
77
+ * @returns {number} returns.mainTurns - Turns in main session since sidecar started
78
+ * @returns {boolean} returns.isSignificant - Whether drift exceeds thresholds
79
+ *
80
+ * @example
81
+ * const drift = calculateDrift(sessionStartTime, '/path/to/main-session.jsonl');
82
+ * // Returns: { ageMinutes: 23, mainTurns: 15, isSignificant: true }
83
+ */
84
+ function calculateDrift(sessionStartTime, mainSessionPath) {
85
+ // Normalize session start time
86
+ const startTime = sessionStartTime instanceof Date
87
+ ? sessionStartTime
88
+ : new Date(sessionStartTime);
89
+
90
+ // Calculate age in minutes
91
+ const ageMs = Date.now() - startTime.getTime();
92
+ const ageMinutes = Math.round(ageMs / 60000);
93
+
94
+ // Count turns in main session since sidecar started
95
+ const mainTurns = countTurnsSince(mainSessionPath, startTime);
96
+
97
+ // Determine if drift is significant per spec §7.3
98
+ const isSignificant = isDriftSignificant(ageMinutes, mainTurns);
99
+
100
+ return {
101
+ ageMinutes,
102
+ mainTurns,
103
+ isSignificant
104
+ };
105
+ }
106
+
107
+ /**
108
+ * Format drift warning for summary output
109
+ * Spec Reference: §7.3 Context Drift Indicator format
110
+ *
111
+ * @param {object} drift - Drift object from calculateDrift
112
+ * @returns {string} Formatted drift indicator string
113
+ *
114
+ * @example
115
+ * formatDriftWarning({ ageMinutes: 23, mainTurns: 15, isSignificant: true })
116
+ * // Returns:
117
+ * // 📍 **Context Age:** 23 minutes (15 conversation turns in main session)
118
+ * //
119
+ * // ⚠️ **Drift Warning:** Main session has continued significantly since this
120
+ * // sidecar started. Verify recommendations against current project state.
121
+ */
122
+ function formatDriftWarning(drift) {
123
+ if (!drift) {
124
+ return '';
125
+ }
126
+
127
+ const lines = [
128
+ `\uD83D\uDCCD **Context Age:** ${drift.ageMinutes} minutes (${drift.mainTurns} conversation turns in main session)`
129
+ ];
130
+
131
+ if (drift.isSignificant) {
132
+ lines.push('');
133
+ lines.push('\u26A0\uFE0F **Drift Warning:** Main session has continued significantly since this sidecar started. Verify recommendations against current project state.');
134
+ }
135
+
136
+ return lines.join('\n');
137
+ }
138
+
139
+ module.exports = {
140
+ calculateDrift,
141
+ formatDriftWarning,
142
+ countTurnsSince,
143
+ isDriftSignificant
144
+ };
@@ -0,0 +1,157 @@
1
+ /**
2
+ * Environment Detection Module
3
+ *
4
+ * Detects the client type (code-local, code-web, cowork), determines
5
+ * display availability, and resolves the session root directory based
6
+ * on platform and CLI arguments.
7
+ */
8
+
9
+ const path = require('path');
10
+ const os = require('os');
11
+ const { logger } = require('./utils/logger');
12
+
13
+ /**
14
+ * Valid client types for the --client flag
15
+ * @type {string[]}
16
+ */
17
+ const VALID_CLIENTS = ['code-local', 'code-web', 'cowork'];
18
+
19
+ /**
20
+ * Infer the client type from args and platform
21
+ *
22
+ * @param {object} args - Parsed CLI arguments
23
+ * @param {string} [args.client] - Explicit client type
24
+ * @param {string} platform - OS platform (e.g., 'darwin', 'linux', 'win32')
25
+ * @returns {string} The inferred client type
26
+ * @throws {Error} If args.client is provided but not a valid value
27
+ */
28
+ function inferClient(args, platform) {
29
+ if (args.client) {
30
+ if (!VALID_CLIENTS.includes(args.client)) {
31
+ throw new Error(
32
+ `Invalid client '${args.client}'. Valid values: ${VALID_CLIENTS.join(', ')}`
33
+ );
34
+ }
35
+ logger.debug('Using explicit client', { client: args.client });
36
+ return args.client;
37
+ }
38
+
39
+ if (platform === 'darwin') {
40
+ logger.debug('Detected code-local on macOS');
41
+ return 'code-local';
42
+ }
43
+
44
+ if (process.env.DISPLAY || process.env.WAYLAND_DISPLAY) {
45
+ logger.debug('Detected code-local via display env var', {
46
+ DISPLAY: process.env.DISPLAY || null,
47
+ WAYLAND_DISPLAY: process.env.WAYLAND_DISPLAY || null
48
+ });
49
+ return 'code-local';
50
+ }
51
+
52
+ logger.debug('No display detected, defaulting to code-web', { platform });
53
+ return 'code-web';
54
+ }
55
+
56
+ /**
57
+ * Encode a filesystem path for use as a directory name.
58
+ * Replaces /, \, the drive-letter colon, and _ with dashes (matches Claude Code).
59
+ *
60
+ * @param {string} cwdPath - The working directory path
61
+ * @returns {string} Encoded path safe for directory names
62
+ */
63
+ function encodePath(cwdPath) {
64
+ return cwdPath.replace(/[/\\:_]/g, '-');
65
+ }
66
+
67
+ /**
68
+ * Resolve the cowork local-agent-mode-sessions root for the given platform.
69
+ * Cowork stores session audit logs inside Claude Desktop's Application Support
70
+ * under local-agent-mode-sessions/<org>/<user>/local_<id>/audit.jsonl.
71
+ *
72
+ * @param {string} platform - OS platform
73
+ * @returns {string} Cowork session root directory
74
+ */
75
+ function getCoworkRoot(platform) {
76
+ const homedir = os.homedir();
77
+
78
+ if (platform === 'darwin') {
79
+ return path.join(homedir, 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
80
+ }
81
+
82
+ if (platform === 'win32') {
83
+ const appdata = process.env.APPDATA || path.join(homedir, 'AppData', 'Roaming');
84
+ return path.join(appdata, 'Claude', 'local-agent-mode-sessions');
85
+ }
86
+
87
+ // Linux and other Unix-like systems
88
+ return path.join(homedir, '.config', 'Claude', 'local-agent-mode-sessions');
89
+ }
90
+
91
+ /**
92
+ * Get the session root directory based on client type and platform
93
+ *
94
+ * @param {object} args - Parsed CLI arguments
95
+ * @param {string} [args.sessionDir] - Explicit session directory
96
+ * @param {string} args.client - Client type (code-local, code-web, cowork)
97
+ * @param {string} [args.cwd] - Working directory (defaults to process.cwd())
98
+ * @param {string} platform - OS platform
99
+ * @returns {string} Resolved session root directory path
100
+ * @throws {Error} If client is code-web and --session-dir is not provided
101
+ */
102
+ function getSessionRoot(args, platform) {
103
+ if (args.sessionDir) {
104
+ logger.debug('Using explicit session directory', { sessionDir: args.sessionDir });
105
+ return args.sessionDir;
106
+ }
107
+
108
+ const { client } = args;
109
+
110
+ if (client === 'code-local') {
111
+ const cwd = args.cwd || process.cwd();
112
+ const encodedPath = encodePath(cwd);
113
+ const sessionRoot = path.join(os.homedir(), '.claude', 'projects', encodedPath);
114
+ logger.debug('Resolved code-local session root', { cwd, sessionRoot });
115
+ return sessionRoot;
116
+ }
117
+
118
+ if (client === 'code-web') {
119
+ throw new Error('--session-dir is required when --client is code-web');
120
+ }
121
+
122
+ if (client === 'cowork') {
123
+ const sessionRoot = getCoworkRoot(platform);
124
+ logger.debug('Resolved cowork session root', { platform, sessionRoot });
125
+ return sessionRoot;
126
+ }
127
+
128
+ throw new Error(`Unknown client type: ${client}`);
129
+ }
130
+
131
+ /**
132
+ * Detect the full environment configuration
133
+ *
134
+ * @param {object} args - Parsed CLI arguments
135
+ * @param {string} [args.client] - Explicit client type
136
+ * @param {string} [args.sessionDir] - Explicit session directory
137
+ * @param {string} [args.cwd] - Working directory
138
+ * @param {string} platform - OS platform
139
+ * @returns {{ client: string, hasDisplay: boolean, sessionRoot: string }}
140
+ */
141
+ function detectEnvironment(args, platform) {
142
+ const client = inferClient(args, platform);
143
+ const hasDisplay = client !== 'code-web';
144
+ const resolvedArgs = { ...args, client };
145
+ const sessionRoot = getSessionRoot(resolvedArgs, platform);
146
+
147
+ logger.info('Environment detected', { client, hasDisplay, sessionRoot });
148
+
149
+ return { client, hasDisplay, sessionRoot };
150
+ }
151
+
152
+ module.exports = {
153
+ inferClient,
154
+ getSessionRoot,
155
+ detectEnvironment,
156
+ VALID_CLIENTS
157
+ };