@hopping-dev/hub 0.1.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 (165) hide show
  1. package/assets/hopping-skill/SKILL.md +221 -0
  2. package/dist/approval/manager.d.ts +60 -0
  3. package/dist/approval/manager.d.ts.map +1 -0
  4. package/dist/approval/manager.js +101 -0
  5. package/dist/approval/manager.js.map +1 -0
  6. package/dist/approval/session-memory.d.ts +37 -0
  7. package/dist/approval/session-memory.d.ts.map +1 -0
  8. package/dist/approval/session-memory.js +63 -0
  9. package/dist/approval/session-memory.js.map +1 -0
  10. package/dist/cli/config-writer.d.ts +57 -0
  11. package/dist/cli/config-writer.d.ts.map +1 -0
  12. package/dist/cli/config-writer.js +318 -0
  13. package/dist/cli/config-writer.js.map +1 -0
  14. package/dist/cli/index.d.ts +3 -0
  15. package/dist/cli/index.d.ts.map +1 -0
  16. package/dist/cli/index.js +82 -0
  17. package/dist/cli/index.js.map +1 -0
  18. package/dist/cli/path-resolver.d.ts +48 -0
  19. package/dist/cli/path-resolver.d.ts.map +1 -0
  20. package/dist/cli/path-resolver.js +212 -0
  21. package/dist/cli/path-resolver.js.map +1 -0
  22. package/dist/cli/setup.d.ts +10 -0
  23. package/dist/cli/setup.d.ts.map +1 -0
  24. package/dist/cli/setup.js +268 -0
  25. package/dist/cli/setup.js.map +1 -0
  26. package/dist/cloud/connector.d.ts +74 -0
  27. package/dist/cloud/connector.d.ts.map +1 -0
  28. package/dist/cloud/connector.js +524 -0
  29. package/dist/cloud/connector.js.map +1 -0
  30. package/dist/cloud/index.d.ts +3 -0
  31. package/dist/cloud/index.d.ts.map +1 -0
  32. package/dist/cloud/index.js +6 -0
  33. package/dist/cloud/index.js.map +1 -0
  34. package/dist/config/manager.d.ts +76 -0
  35. package/dist/config/manager.d.ts.map +1 -0
  36. package/dist/config/manager.js +296 -0
  37. package/dist/config/manager.js.map +1 -0
  38. package/dist/dev-mode.d.ts +30 -0
  39. package/dist/dev-mode.d.ts.map +1 -0
  40. package/dist/dev-mode.js +53 -0
  41. package/dist/dev-mode.js.map +1 -0
  42. package/dist/index.d.ts +10 -0
  43. package/dist/index.d.ts.map +1 -0
  44. package/dist/index.js +354 -0
  45. package/dist/index.js.map +1 -0
  46. package/dist/ipc/index.d.ts +12 -0
  47. package/dist/ipc/index.d.ts.map +1 -0
  48. package/dist/ipc/index.js +15 -0
  49. package/dist/ipc/index.js.map +1 -0
  50. package/dist/ipc/watcher.d.ts +226 -0
  51. package/dist/ipc/watcher.d.ts.map +1 -0
  52. package/dist/ipc/watcher.js +745 -0
  53. package/dist/ipc/watcher.js.map +1 -0
  54. package/dist/local/approval-dialog.d.ts +30 -0
  55. package/dist/local/approval-dialog.d.ts.map +1 -0
  56. package/dist/local/approval-dialog.js +214 -0
  57. package/dist/local/approval-dialog.js.map +1 -0
  58. package/dist/local/index.d.ts +8 -0
  59. package/dist/local/index.d.ts.map +1 -0
  60. package/dist/local/index.js +13 -0
  61. package/dist/local/index.js.map +1 -0
  62. package/dist/local/local-approval.d.ts +55 -0
  63. package/dist/local/local-approval.d.ts.map +1 -0
  64. package/dist/local/local-approval.js +125 -0
  65. package/dist/local/local-approval.js.map +1 -0
  66. package/dist/local/notifier.d.ts +19 -0
  67. package/dist/local/notifier.d.ts.map +1 -0
  68. package/dist/local/notifier.js +110 -0
  69. package/dist/local/notifier.js.map +1 -0
  70. package/dist/local/sanitize.d.ts +20 -0
  71. package/dist/local/sanitize.d.ts.map +1 -0
  72. package/dist/local/sanitize.js +28 -0
  73. package/dist/local/sanitize.js.map +1 -0
  74. package/dist/mcp/file-extractor.d.ts +11 -0
  75. package/dist/mcp/file-extractor.d.ts.map +1 -0
  76. package/dist/mcp/file-extractor.js +74 -0
  77. package/dist/mcp/file-extractor.js.map +1 -0
  78. package/dist/mcp/risk-level.d.ts +44 -0
  79. package/dist/mcp/risk-level.d.ts.map +1 -0
  80. package/dist/mcp/risk-level.js +127 -0
  81. package/dist/mcp/risk-level.js.map +1 -0
  82. package/dist/mcp/schemas.d.ts +83 -0
  83. package/dist/mcp/schemas.d.ts.map +1 -0
  84. package/dist/mcp/schemas.js +84 -0
  85. package/dist/mcp/schemas.js.map +1 -0
  86. package/dist/mcp/summary.d.ts +11 -0
  87. package/dist/mcp/summary.d.ts.map +1 -0
  88. package/dist/mcp/summary.js +150 -0
  89. package/dist/mcp/summary.js.map +1 -0
  90. package/dist/mcp/tools.d.ts +45 -0
  91. package/dist/mcp/tools.d.ts.map +1 -0
  92. package/dist/mcp/tools.js +1217 -0
  93. package/dist/mcp/tools.js.map +1 -0
  94. package/dist/pairing/auto-pairing.d.ts +37 -0
  95. package/dist/pairing/auto-pairing.d.ts.map +1 -0
  96. package/dist/pairing/auto-pairing.js +144 -0
  97. package/dist/pairing/auto-pairing.js.map +1 -0
  98. package/dist/pairing/binding-poller.d.ts +26 -0
  99. package/dist/pairing/binding-poller.d.ts.map +1 -0
  100. package/dist/pairing/binding-poller.js +108 -0
  101. package/dist/pairing/binding-poller.js.map +1 -0
  102. package/dist/pairing/pairing-server.d.ts +14 -0
  103. package/dist/pairing/pairing-server.d.ts.map +1 -0
  104. package/dist/pairing/pairing-server.js +277 -0
  105. package/dist/pairing/pairing-server.js.map +1 -0
  106. package/dist/pairing/qr-display.d.ts +14 -0
  107. package/dist/pairing/qr-display.d.ts.map +1 -0
  108. package/dist/pairing/qr-display.js +40 -0
  109. package/dist/pairing/qr-display.js.map +1 -0
  110. package/dist/policy/engine.d.ts +31 -0
  111. package/dist/policy/engine.d.ts.map +1 -0
  112. package/dist/policy/engine.js +187 -0
  113. package/dist/policy/engine.js.map +1 -0
  114. package/dist/policy/store.d.ts +26 -0
  115. package/dist/policy/store.d.ts.map +1 -0
  116. package/dist/policy/store.js +70 -0
  117. package/dist/policy/store.js.map +1 -0
  118. package/dist/policy/system-policies.d.ts +15 -0
  119. package/dist/policy/system-policies.d.ts.map +1 -0
  120. package/dist/policy/system-policies.js +265 -0
  121. package/dist/policy/system-policies.js.map +1 -0
  122. package/dist/policy/tool-mapping.d.ts +45 -0
  123. package/dist/policy/tool-mapping.d.ts.map +1 -0
  124. package/dist/policy/tool-mapping.js +88 -0
  125. package/dist/policy/tool-mapping.js.map +1 -0
  126. package/dist/policy/tool-registry.json +85 -0
  127. package/dist/store/db.d.ts +17 -0
  128. package/dist/store/db.d.ts.map +1 -0
  129. package/dist/store/db.js +193 -0
  130. package/dist/store/db.js.map +1 -0
  131. package/dist/store/index.d.ts +4 -0
  132. package/dist/store/index.d.ts.map +1 -0
  133. package/dist/store/index.js +7 -0
  134. package/dist/store/index.js.map +1 -0
  135. package/dist/store/metadata.d.ts +31 -0
  136. package/dist/store/metadata.d.ts.map +1 -0
  137. package/dist/store/metadata.js +178 -0
  138. package/dist/store/metadata.js.map +1 -0
  139. package/dist/store/operations.d.ts +26 -0
  140. package/dist/store/operations.d.ts.map +1 -0
  141. package/dist/store/operations.js +171 -0
  142. package/dist/store/operations.js.map +1 -0
  143. package/dist/utils/json.d.ts +7 -0
  144. package/dist/utils/json.d.ts.map +1 -0
  145. package/dist/utils/json.js +33 -0
  146. package/dist/utils/json.js.map +1 -0
  147. package/dist/utils/logger.d.ts +13 -0
  148. package/dist/utils/logger.d.ts.map +1 -0
  149. package/dist/utils/logger.js +58 -0
  150. package/dist/utils/logger.js.map +1 -0
  151. package/dist/utils/open-browser.d.ts +8 -0
  152. package/dist/utils/open-browser.d.ts.map +1 -0
  153. package/dist/utils/open-browser.js +38 -0
  154. package/dist/utils/open-browser.js.map +1 -0
  155. package/node_modules/@hopping/shared/dist/types.d.ts +649 -0
  156. package/node_modules/@hopping/shared/dist/types.js +48 -0
  157. package/node_modules/@hopping/shared/dist/types.js.map +1 -0
  158. package/node_modules/@hopping/shared/package.json +14 -0
  159. package/node_modules/@hopping/shared/tsconfig.json +16 -0
  160. package/node_modules/@hopping/shared/types.d.ts +650 -0
  161. package/node_modules/@hopping/shared/types.d.ts.map +1 -0
  162. package/node_modules/@hopping/shared/types.js +48 -0
  163. package/node_modules/@hopping/shared/types.js.map +1 -0
  164. package/node_modules/@hopping/shared/types.ts +895 -0
  165. package/package.json +52 -0
@@ -0,0 +1,1217 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports._resetAskMode = _resetAskMode;
7
+ exports.getAskMode = getAskMode;
8
+ exports.getToolDefinitions = getToolDefinitions;
9
+ exports.createToolHandler = createToolHandler;
10
+ const crypto_1 = require("crypto");
11
+ const package_json_1 = __importDefault(require("../../package.json"));
12
+ const schemas_1 = require("./schemas");
13
+ const cloud_1 = require("../cloud");
14
+ const manager_1 = require("../config/manager");
15
+ const file_extractor_1 = require("./file-extractor");
16
+ const summary_1 = require("./summary");
17
+ const risk_level_1 = require("./risk-level");
18
+ const tool_mapping_1 = require("../policy/tool-mapping");
19
+ // 從 package.json 讀取版本號(避免硬編碼)
20
+ const HUB_VERSION = package_json_1.default.version;
21
+ // ============================================
22
+ // Ask Mode State (REQ-HUB-041)
23
+ // ============================================
24
+ /** Hub 記憶體中的 Ask Mode(新 Session 自動 reset 為 'local') */
25
+ let askMode = 'local';
26
+ /** 供測試用:重設 askMode(Hub 重啟模擬) */
27
+ function _resetAskMode() {
28
+ askMode = 'local';
29
+ }
30
+ /** 取得當前 askMode(供 IpcWatcher 及測試使用) */
31
+ function getAskMode() {
32
+ return askMode;
33
+ }
34
+ // ============================================
35
+ // Tool Definitions
36
+ // ============================================
37
+ function getToolDefinitions() {
38
+ return [
39
+ {
40
+ name: 'hopping.check',
41
+ description: 'Request approval for a tool before executing it. Hub evaluates the request against local policies and returns ALLOW or DENY.',
42
+ inputSchema: {
43
+ type: 'object',
44
+ properties: {
45
+ toolName: {
46
+ type: 'string',
47
+ description: 'The name of the tool to be checked',
48
+ },
49
+ toolInput: {
50
+ type: 'object',
51
+ description: 'The input parameters of the tool to be checked',
52
+ },
53
+ sessionId: {
54
+ type: 'string',
55
+ description: 'Current session ID',
56
+ },
57
+ cwd: {
58
+ type: 'string',
59
+ description: 'Current working directory',
60
+ },
61
+ context: {
62
+ type: 'object',
63
+ description: 'Additional context including task information',
64
+ properties: {
65
+ recentFiles: { type: 'array', items: { type: 'string' } },
66
+ branch: { type: 'string' },
67
+ project: { type: 'string' },
68
+ taskDescription: { type: 'string', description: 'What task the agent is working on' },
69
+ reasoning: { type: 'string', description: 'Why this operation is needed' },
70
+ userPrompt: {
71
+ type: 'string',
72
+ description: "The user's original prompt (stored locally only)",
73
+ },
74
+ },
75
+ },
76
+ },
77
+ required: ['toolName', 'toolInput'],
78
+ },
79
+ },
80
+ {
81
+ name: 'hopping.report',
82
+ description: 'Report a completed operation to Hub for logging and observation. Used by agents without Hook support.',
83
+ inputSchema: {
84
+ type: 'object',
85
+ properties: {
86
+ toolName: {
87
+ type: 'string',
88
+ description: 'The name of the tool that was executed',
89
+ },
90
+ toolParams: {
91
+ type: 'object',
92
+ description: 'Parameters passed to the tool',
93
+ },
94
+ result: {
95
+ type: 'string',
96
+ description: 'Execution result summary',
97
+ },
98
+ duration: {
99
+ type: 'number',
100
+ description: 'Execution duration in milliseconds',
101
+ },
102
+ success: {
103
+ type: 'boolean',
104
+ description: 'Whether the execution was successful',
105
+ },
106
+ filePaths: {
107
+ type: 'array',
108
+ items: { type: 'string' },
109
+ description: 'File paths affected by the operation',
110
+ },
111
+ },
112
+ required: ['toolName'],
113
+ },
114
+ },
115
+ {
116
+ name: 'hopping.status',
117
+ description: 'Query the current status of the HopPing Hub.',
118
+ inputSchema: {
119
+ type: 'object',
120
+ properties: {
121
+ query: {
122
+ type: 'string',
123
+ enum: ['sessions', 'pending', 'stats', 'all'],
124
+ description: 'Type of status query',
125
+ },
126
+ agentId: {
127
+ type: 'string',
128
+ description: 'Filter by specific agent ID',
129
+ },
130
+ },
131
+ },
132
+ },
133
+ {
134
+ name: 'hopping.respond',
135
+ description: 'Submit a local approval decision for a pending operation. Used when hopping.check returned decision="pending" and the user is at the computer to approve or deny locally.',
136
+ inputSchema: {
137
+ type: 'object',
138
+ properties: {
139
+ approvalId: {
140
+ type: 'string',
141
+ description: 'The approvalId returned by hopping.check when decision was "pending"',
142
+ },
143
+ decision: {
144
+ type: 'string',
145
+ enum: ['approved', 'denied'],
146
+ description: 'The user decision: "approved" or "denied"',
147
+ },
148
+ reason: {
149
+ type: 'string',
150
+ description: 'Optional reason for the decision',
151
+ },
152
+ },
153
+ required: ['approvalId', 'decision'],
154
+ },
155
+ },
156
+ {
157
+ name: 'hopping.pair',
158
+ description: 'Pair this Hub with a HopPing account using a pairing code. Run this once to link the Hub to your mobile app. The token is saved locally and used automatically on future starts.',
159
+ inputSchema: {
160
+ type: 'object',
161
+ properties: {
162
+ pairingCode: {
163
+ type: 'string',
164
+ description: 'The 6-character pairing code shown in the HopPing mobile app',
165
+ },
166
+ cloudUrl: {
167
+ type: 'string',
168
+ description: 'Override the Cloud Backend URL (optional, defaults to HOPPING_CLOUD_URL)',
169
+ },
170
+ },
171
+ required: ['pairingCode'],
172
+ },
173
+ },
174
+ {
175
+ name: 'hopping.mode',
176
+ description: 'Switch between local and away mode. Local mode (default): hopping.ask returns immediately, agent asks the user directly. Away mode: hopping.ask sends questions to the mobile app and waits for response.',
177
+ inputSchema: {
178
+ type: 'object',
179
+ properties: {
180
+ mode: {
181
+ type: 'string',
182
+ enum: ['local', 'away'],
183
+ description: 'The mode to switch to: "local" (user at computer) or "away" (user away)',
184
+ },
185
+ },
186
+ required: ['mode'],
187
+ },
188
+ },
189
+ {
190
+ name: 'hopping.ask',
191
+ description: 'Ask the user a non-approval question (choice or freetext). In local mode, returns immediately so the agent can ask directly. In away mode, sends the question to the mobile app and waits for response.',
192
+ inputSchema: {
193
+ type: 'object',
194
+ properties: {
195
+ question: {
196
+ type: 'string',
197
+ description: 'The question to ask the user',
198
+ },
199
+ responseType: {
200
+ type: 'string',
201
+ enum: ['choice', 'freetext'],
202
+ description: 'Expected response format: choice (select from options) or freetext (open input)',
203
+ },
204
+ options: {
205
+ type: 'array',
206
+ description: 'Available options for choice responseType',
207
+ items: {
208
+ type: 'object',
209
+ properties: {
210
+ id: { type: 'string' },
211
+ label: { type: 'string' },
212
+ description: { type: 'string' },
213
+ isOther: { type: 'boolean' },
214
+ },
215
+ required: ['id', 'label'],
216
+ },
217
+ },
218
+ placeholder: {
219
+ type: 'string',
220
+ description: 'Placeholder text for freetext input',
221
+ },
222
+ context: {
223
+ type: 'object',
224
+ description: 'Task context for the question',
225
+ properties: {
226
+ taskDescription: { type: 'string' },
227
+ reasoning: { type: 'string' },
228
+ },
229
+ },
230
+ },
231
+ required: ['question', 'responseType'],
232
+ },
233
+ },
234
+ ];
235
+ }
236
+ function validateInput(schema, args) {
237
+ const parsed = schema.safeParse(args);
238
+ if (parsed.success) {
239
+ return { ok: true, data: parsed.data };
240
+ }
241
+ const messages = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
242
+ return {
243
+ ok: false,
244
+ result: {
245
+ content: [
246
+ { type: 'text', text: JSON.stringify({ error: 'Validation failed', details: messages }) },
247
+ ],
248
+ isError: true,
249
+ },
250
+ };
251
+ }
252
+ // ============================================
253
+ // Approval Config Helper (S2-HUB-APR-002)
254
+ // ============================================
255
+ async function getApprovalConfigSafe(deps) {
256
+ if (!deps.configManager)
257
+ return { ...manager_1.DEFAULT_APPROVAL_CONFIG };
258
+ try {
259
+ return await deps.configManager.getApprovalConfig();
260
+ }
261
+ catch {
262
+ return { ...manager_1.DEFAULT_APPROVAL_CONFIG };
263
+ }
264
+ }
265
+ function buildLocalDecisionMessage(decision, source) {
266
+ if (decision === 'approved') {
267
+ return source === 'local_dialog'
268
+ ? 'Approved locally via dialog'
269
+ : 'Approved locally via terminal (hopping.respond)';
270
+ }
271
+ if (decision === 'denied') {
272
+ return source === 'local_dialog'
273
+ ? 'Denied locally via dialog'
274
+ : 'Denied locally via terminal (hopping.respond)';
275
+ }
276
+ // timeout / pending / etc — caller sets the message via buildTimeoutMessage
277
+ return `Local approval result: ${decision}`;
278
+ }
279
+ function buildTimeoutMessage(onTimeout, riskLevel, timeoutMs) {
280
+ const seconds = Math.round(timeoutMs / 1000);
281
+ switch (onTimeout) {
282
+ case 'pending':
283
+ return `⏳ Remote approval timed out after ${seconds}s. This ${riskLevel}-risk operation requires your approval. Please approve or deny to proceed.`;
284
+ case 'timeout':
285
+ return `Remote approval timed out after ${seconds}s (${riskLevel} risk). Follow Skill rules for timeout decisions.`;
286
+ case 'denied':
287
+ return `Denied: remote approval timed out after ${seconds}s (${riskLevel} risk).`;
288
+ case 'approved':
289
+ return `Auto-approved after timeout (${seconds}s, ${riskLevel} risk). User configured onTimeout=approved.`;
290
+ default:
291
+ return `Remote approval timed out after ${seconds}s.`;
292
+ }
293
+ }
294
+ // ============================================
295
+ // Store Write Helper (de-duplicated from handleCheck)
296
+ // ============================================
297
+ /** Max chars for taskDescription/reasoning sent to Cloud */
298
+ const TASK_CONTEXT_TRUNCATE_LENGTH = 200;
299
+ function truncateField(value, maxLength) {
300
+ if (value === undefined)
301
+ return undefined;
302
+ if (value.length <= maxLength)
303
+ return value;
304
+ return value.slice(0, maxLength - 3) + '...';
305
+ }
306
+ /**
307
+ * Build taskContext for Cloud payload (security layering: NO userPrompt).
308
+ */
309
+ function buildTaskContext(context) {
310
+ if (!context?.taskDescription && !context?.reasoning)
311
+ return undefined;
312
+ return {
313
+ taskDescription: truncateField(context.taskDescription, TASK_CONTEXT_TRUNCATE_LENGTH),
314
+ reasoning: truncateField(context.reasoning, TASK_CONTEXT_TRUNCATE_LENGTH),
315
+ };
316
+ }
317
+ /**
318
+ * Build contextTags JSON for metadata table (includes ALL context fields).
319
+ */
320
+ function buildContextTags(input) {
321
+ const tags = {};
322
+ if (input.context?.branch)
323
+ tags['branch'] = input.context.branch;
324
+ if (input.context?.project)
325
+ tags['project'] = input.context.project;
326
+ if (input.context?.taskDescription)
327
+ tags['taskDescription'] = input.context.taskDescription;
328
+ if (input.context?.reasoning)
329
+ tags['reasoning'] = input.context.reasoning;
330
+ if (input.context?.userPrompt)
331
+ tags['userPrompt'] = input.context.userPrompt;
332
+ return tags;
333
+ }
334
+ function writeCheckOperation(deps, params) {
335
+ if (!deps.store)
336
+ return;
337
+ deps.store.operations.insert({
338
+ id: params.id,
339
+ approvalId: null,
340
+ toolName: params.toolName,
341
+ toolParams: params.toolInput ? JSON.stringify(params.toolInput) : null,
342
+ filePaths: params.filePaths.length > 0 ? JSON.stringify(params.filePaths) : null,
343
+ riskLevel: params.riskLevel,
344
+ decision: params.decision,
345
+ createdAt: params.now,
346
+ decidedAt: params.now,
347
+ toolUseId: null,
348
+ promptType: params.promptType ?? null,
349
+ responseType: params.responseType ?? null,
350
+ });
351
+ }
352
+ /**
353
+ * Write a pending operation (decision=null) to SQLite, awaiting user response.
354
+ * Used by both Cloud and Local approval paths.
355
+ */
356
+ function writePendingCheckOperation(deps, params) {
357
+ if (!deps.store)
358
+ return;
359
+ deps.store.operations.insert({
360
+ id: params.id,
361
+ approvalId: null,
362
+ toolName: params.toolName,
363
+ toolParams: params.toolInput ? JSON.stringify(params.toolInput) : null,
364
+ filePaths: params.filePaths.length > 0 ? JSON.stringify(params.filePaths) : null,
365
+ riskLevel: params.riskLevel,
366
+ decision: null,
367
+ createdAt: params.now,
368
+ decidedAt: null,
369
+ toolUseId: null,
370
+ promptType: (params.promptType ?? null),
371
+ responseType: (params.responseType ?? null),
372
+ });
373
+ }
374
+ /**
375
+ * Write metadata record for a hopping.check call.
376
+ */
377
+ function writeCheckMetadata(deps, params) {
378
+ if (!deps.store)
379
+ return;
380
+ const contextTags = buildContextTags(params.input);
381
+ deps.store.metadata.insert({
382
+ operationId: params.operationId,
383
+ agentType: deps.agentType ?? 'generic_mcp',
384
+ sessionId: params.input.sessionId ?? deps.agentId ?? 'unknown',
385
+ durationMs: null,
386
+ tokenUsage: null,
387
+ contextTags: Object.keys(contextTags).length > 0 ? JSON.stringify(contextTags) : null,
388
+ source: 'check',
389
+ resultStatus: null,
390
+ toolResultSummary: null,
391
+ createdAt: params.now,
392
+ });
393
+ }
394
+ // ============================================
395
+ // Tool Handler Factory
396
+ // ============================================
397
+ function createToolHandler(deps) {
398
+ return async (name, args) => {
399
+ switch (name) {
400
+ case 'hopping.check': {
401
+ const v = validateInput(schemas_1.HoppingCheckInputSchema, args);
402
+ return v.ok ? handleCheck(v.data, deps) : v.result;
403
+ }
404
+ case 'hopping.report': {
405
+ const v = validateInput(schemas_1.HoppingReportInputSchema, args);
406
+ return v.ok ? handleReport(v.data, deps) : v.result;
407
+ }
408
+ case 'hopping.respond': {
409
+ const v = validateInput(schemas_1.HoppingRespondInputSchema, args);
410
+ return v.ok ? handleRespond(v.data, deps) : v.result;
411
+ }
412
+ case 'hopping.status': {
413
+ const v = validateInput(schemas_1.HoppingStatusInputSchema, args);
414
+ return v.ok ? handleStatus(v.data, deps) : v.result;
415
+ }
416
+ case 'hopping.pair': {
417
+ const v = validateInput(schemas_1.HoppingPairInputSchema, args);
418
+ return v.ok ? handlePair(v.data, deps) : v.result;
419
+ }
420
+ case 'hopping.mode': {
421
+ const v = validateInput(schemas_1.HoppingModeInputSchema, args);
422
+ return v.ok ? handleMode(v.data, deps) : v.result;
423
+ }
424
+ case 'hopping.ask': {
425
+ const v = validateInput(schemas_1.HoppingAskInputSchema, args);
426
+ return v.ok ? handleAsk(v.data, deps) : v.result;
427
+ }
428
+ default:
429
+ return {
430
+ content: [{ type: 'text', text: JSON.stringify({ error: `Unknown tool: ${name}` }) }],
431
+ isError: true,
432
+ };
433
+ }
434
+ };
435
+ }
436
+ // ============================================
437
+ // hopping.check Handler
438
+ // ============================================
439
+ async function handleCheck(input, deps) {
440
+ const { policyEngine, logger, shouldIntercept } = deps;
441
+ const approvalId = (0, crypto_1.randomUUID)();
442
+ const now = new Date().toISOString();
443
+ // Extract file paths from toolInput (used in all paths)
444
+ const filePaths = (0, file_extractor_1.extractFilePaths)(input.toolName, input.toolInput);
445
+ // Dev mode: bypass policy if not intercepting
446
+ if (!shouldIntercept(input.toolName)) {
447
+ logger.debug('hopping.check: dev mode bypass', { toolName: input.toolName, approvalId });
448
+ const output = {
449
+ approvalId,
450
+ decision: 'approved',
451
+ riskLevel: 'low',
452
+ policyResult: {
453
+ requestId: approvalId,
454
+ finalAction: 'ALLOW',
455
+ triggeredPolicies: [],
456
+ evaluationTimeMs: 0,
457
+ evaluatedAt: now,
458
+ },
459
+ message: 'Auto-approved (dev mode)',
460
+ waitedForRemote: false,
461
+ };
462
+ writeCheckOperation(deps, {
463
+ id: approvalId,
464
+ toolName: input.toolName,
465
+ toolInput: input.toolInput,
466
+ filePaths,
467
+ riskLevel: 'low',
468
+ decision: 'approved',
469
+ now,
470
+ });
471
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
472
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
473
+ }
474
+ // Build evaluation context
475
+ const agentId = deps.agentId ?? 'unknown';
476
+ const agentType = deps.agentType ?? 'generic_mcp';
477
+ // Three-layer tool mapping resolution
478
+ const userMappings = deps.configManager ? await deps.configManager.getToolMappings() : null;
479
+ const bundledRegistry = (0, tool_mapping_1.loadBundledRegistry)();
480
+ const mappingEntry = (0, tool_mapping_1.resolveToolMapping)(input.toolName, agentType, userMappings, bundledRegistry);
481
+ const operationFields = (0, tool_mapping_1.extractOperationFields)(mappingEntry, input.toolInput);
482
+ const context = {
483
+ requestId: approvalId,
484
+ agentId,
485
+ agentType,
486
+ operation: {
487
+ type: mappingEntry.type,
488
+ toolName: input.toolName,
489
+ target: input.cwd,
490
+ command: operationFields.command,
491
+ filePath: operationFields.filePath,
492
+ apiEndpoint: operationFields.apiEndpoint,
493
+ },
494
+ session: {
495
+ costUsd: 0,
496
+ durationSeconds: (Date.now() - deps.startTime) / 1000,
497
+ approvalCount: 0,
498
+ },
499
+ timestamp: now,
500
+ };
501
+ const policyResult = await policyEngine.evaluate(context);
502
+ const riskLevel = (0, risk_level_1.deriveRiskLevel)(policyResult.finalAction, input.toolName, input.toolInput);
503
+ // Generate smart summary
504
+ const summary = (0, summary_1.generateSummary)(input.toolName, input.toolInput);
505
+ // Build task context for Cloud (security layering: NO userPrompt)
506
+ const taskContext = buildTaskContext(input.context);
507
+ // Handle YOLO mode: auto-approve(policy 仍執行,取得 riskLevel 用於觀測)
508
+ if (deps.isYoloMode?.()) {
509
+ const output = {
510
+ approvalId,
511
+ decision: 'approved',
512
+ riskLevel,
513
+ policyResult,
514
+ message: 'Auto-approved (YOLO observe mode)',
515
+ waitedForRemote: false,
516
+ };
517
+ writeCheckOperation(deps, {
518
+ id: approvalId,
519
+ toolName: input.toolName,
520
+ toolInput: input.toolInput,
521
+ filePaths,
522
+ riskLevel,
523
+ decision: 'approved',
524
+ now,
525
+ });
526
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
527
+ const cloudAgentId = deps.cloudConnector?.cloudAgentId ?? agentId;
528
+ if (deps.cloudConnector?.isConnected) {
529
+ deps.cloudConnector.sendProgressUpdate((0, risk_level_1.buildYoloProgressPayload)({
530
+ agentId: cloudAgentId,
531
+ sessionId: input.sessionId ?? cloudAgentId,
532
+ toolName: input.toolName,
533
+ riskLevel,
534
+ timestamp: now,
535
+ filePaths: filePaths.length > 0 ? filePaths : undefined,
536
+ }));
537
+ }
538
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
539
+ }
540
+ // Handle ALLOW
541
+ if (policyResult.finalAction === 'ALLOW') {
542
+ const output = {
543
+ approvalId,
544
+ decision: 'approved',
545
+ riskLevel,
546
+ policyResult,
547
+ message: 'Approved by local policy',
548
+ waitedForRemote: false,
549
+ };
550
+ writeCheckOperation(deps, {
551
+ id: approvalId,
552
+ toolName: input.toolName,
553
+ toolInput: input.toolInput,
554
+ filePaths,
555
+ riskLevel,
556
+ decision: 'approved',
557
+ now,
558
+ });
559
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
560
+ if (deps.cloudConnector?.isConnected) {
561
+ const cloudAgentId = deps.cloudConnector.cloudAgentId ?? agentId;
562
+ deps.cloudConnector.sendProgressUpdate({
563
+ agentId: cloudAgentId,
564
+ sessionId: input.sessionId ?? cloudAgentId,
565
+ message: `✅ ${input.toolName} 自動核准`,
566
+ timestamp: now,
567
+ messageType: 'operation_report',
568
+ });
569
+ }
570
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
571
+ }
572
+ // S4-HUB-DONTASK-001: Session memory check — auto-approve if user previously chose "don't ask again"
573
+ if (deps.sessionMemory?.has(input.toolName, riskLevel)) {
574
+ logger.info('hopping.check: session memory hit — auto-approve', {
575
+ toolName: input.toolName,
576
+ riskLevel,
577
+ approvalId,
578
+ });
579
+ const output = {
580
+ approvalId,
581
+ decision: 'approved',
582
+ riskLevel,
583
+ policyResult,
584
+ message: 'Auto-approved (session: dont_ask_again)',
585
+ waitedForRemote: false,
586
+ };
587
+ writeCheckOperation(deps, {
588
+ id: approvalId,
589
+ toolName: input.toolName,
590
+ toolInput: input.toolInput,
591
+ filePaths,
592
+ riskLevel,
593
+ decision: 'approved',
594
+ now,
595
+ });
596
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
597
+ if (deps.cloudConnector?.isConnected) {
598
+ const cloudAgentId = deps.cloudConnector.cloudAgentId ?? agentId;
599
+ deps.cloudConnector.sendProgressUpdate({
600
+ agentId: cloudAgentId,
601
+ sessionId: input.sessionId ?? cloudAgentId,
602
+ message: `✅ ${input.toolName} 自動核准(此工作階段不再詢問)`,
603
+ timestamp: now,
604
+ messageType: 'operation_report',
605
+ });
606
+ }
607
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
608
+ }
609
+ // Handle ASK / WARN — 送 Cloud 審核或 fallback auto-deny
610
+ const { cloudConnector } = deps;
611
+ if (cloudConnector?.isConnected) {
612
+ // Cloud 已連線:送 permission_prompt 並等待 Mobile 回應
613
+ logger.info('hopping.check: sending to Cloud for remote approval', {
614
+ toolName: input.toolName,
615
+ approvalId,
616
+ finalAction: policyResult.finalAction,
617
+ });
618
+ // 先寫入 SQLite(decision 暫為 null,等待 Mobile 回應後更新)
619
+ writePendingCheckOperation(deps, {
620
+ id: approvalId,
621
+ toolName: input.toolName,
622
+ toolInput: input.toolInput,
623
+ filePaths,
624
+ riskLevel,
625
+ now,
626
+ promptType: 'approval',
627
+ responseType: 'binary',
628
+ });
629
+ // 寫入 metadata(含 userPrompt,僅存本地)
630
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
631
+ // 取得 risk-based timeout 設定(需在 sendPermissionPrompt 前取得,以便包含 timeoutMs)
632
+ const approvalConfig = await getApprovalConfigSafe(deps);
633
+ const timeoutMs = approvalConfig.timeouts[riskLevel];
634
+ // 發送 permission_prompt 到 Cloud(含 smart summary + taskContext + timeoutMs + onTimeout + toolContext,不含 userPrompt)
635
+ cloudConnector.sendPermissionPrompt({
636
+ id: approvalId,
637
+ agentId: agentId,
638
+ toolName: input.toolName,
639
+ riskLevel,
640
+ summary,
641
+ policyAction: policyResult.finalAction,
642
+ sessionId: input.sessionId,
643
+ timestamp: now,
644
+ timeoutMs,
645
+ onTimeout: (0, manager_1.toApprovalDecision)(approvalConfig.onTimeout[riskLevel]),
646
+ taskContext,
647
+ promptType: 'approval',
648
+ responseType: 'binary',
649
+ toolContext: {
650
+ description: risk_level_1.OPERATION_TYPE_DESCRIPTIONS[mappingEntry.type] ?? risk_level_1.OPERATION_TYPE_DESCRIPTIONS['unknown'],
651
+ parameters: input.toolInput,
652
+ },
653
+ });
654
+ // 通知 Mobile:操作等待審核中
655
+ cloudConnector.sendProgressUpdate({
656
+ agentId: cloudConnector.cloudAgentId ?? agentId,
657
+ sessionId: input.sessionId ?? agentId,
658
+ message: `⏳ ${input.toolName} 等待審核中...`,
659
+ timestamp: now,
660
+ messageType: 'progress',
661
+ });
662
+ // Race Mode(REQ-HUB-036):Cloud 連線時同時啟動本地對話框,先到先贏
663
+ let raceAbort;
664
+ if (deps.localApprovalChannel?.isRaceModeEnabled) {
665
+ const raceHandle = deps.localApprovalChannel.startRaceDialog({
666
+ approvalId,
667
+ toolName: input.toolName,
668
+ riskLevel,
669
+ summary,
670
+ timeoutMs,
671
+ });
672
+ raceAbort = raceHandle.abort;
673
+ }
674
+ // 等待 ApprovalManager 回傳決策(Cloud permission_response 或 Local dialog 會 resolve)
675
+ // Store 更新由 CloudConnector.handlePermissionResponse 負責(非超時情況)
676
+ const result = await deps.approvalManager.waitForApproval(approvalId, timeoutMs);
677
+ // 清理 Race Mode 的 dialog(Mobile 先到時 kill dialog)
678
+ if (raceAbort)
679
+ raceAbort();
680
+ if (!result.timedOut) {
681
+ // Mobile 或 Dialog 及時回應(非超時)
682
+ const decision = result.approved ? 'approved' : 'denied';
683
+ const sourceLabel = result.source === 'local_dialog' ? 'locally via dialog' : 'remotely';
684
+ const output = {
685
+ approvalId,
686
+ decision,
687
+ riskLevel,
688
+ policyResult,
689
+ message: result.approved
690
+ ? `Approved ${sourceLabel}${result.reason ? `: ${result.reason}` : ''}`
691
+ : `Denied ${sourceLabel}${result.reason ? `: ${result.reason}` : ''}`,
692
+ waitedForRemote: result.source !== 'local_dialog',
693
+ };
694
+ // 通知 Mobile 審核決策結果
695
+ cloudConnector.sendProgressUpdate({
696
+ agentId: cloudConnector.cloudAgentId ?? agentId,
697
+ sessionId: input.sessionId ?? agentId,
698
+ message: result.approved ? `✅ ${input.toolName} 已核准` : `❌ ${input.toolName} 已拒絕`,
699
+ timestamp: new Date().toISOString(),
700
+ messageType: 'operation_report',
701
+ });
702
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
703
+ }
704
+ // 超時:分層處理 — 根據 config.onTimeout[riskLevel](TimeoutAction → TimeoutDecision 轉換)
705
+ const onTimeout = (0, manager_1.toApprovalDecision)(approvalConfig.onTimeout[riskLevel]);
706
+ const decidedAt = new Date().toISOString();
707
+ // 更新 SQLite(超時路徑之前 decision 為 null)
708
+ if (deps.store) {
709
+ deps.store.operations.updateDecision(approvalId, onTimeout, decidedAt);
710
+ }
711
+ logger.info('hopping.check: timeout, applying layered decision', {
712
+ approvalId,
713
+ riskLevel,
714
+ onTimeout,
715
+ timeoutMs,
716
+ });
717
+ const output = {
718
+ approvalId,
719
+ decision: onTimeout,
720
+ riskLevel,
721
+ policyResult,
722
+ message: buildTimeoutMessage(onTimeout, riskLevel, timeoutMs),
723
+ waitedForRemote: true,
724
+ };
725
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
726
+ }
727
+ // Cloud 未連線:Local Approval Channel(REQ-HUB-002 modified)
728
+ if (deps.localApprovalChannel?.isEnabled) {
729
+ logger.info('hopping.check: using local approval channel (Cloud disconnected)', {
730
+ toolName: input.toolName,
731
+ approvalId,
732
+ finalAction: policyResult.finalAction,
733
+ });
734
+ // 先寫入 SQLite(decision 暫為 null,等待用戶回應後更新)
735
+ writePendingCheckOperation(deps, {
736
+ id: approvalId,
737
+ toolName: input.toolName,
738
+ toolInput: input.toolInput,
739
+ filePaths,
740
+ riskLevel,
741
+ now,
742
+ promptType: 'approval',
743
+ responseType: 'binary',
744
+ });
745
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
746
+ const approvalConfig = await getApprovalConfigSafe(deps);
747
+ const timeoutMs = approvalConfig.timeouts[riskLevel];
748
+ const localResult = await deps.localApprovalChannel.requestApproval({
749
+ approvalId,
750
+ toolName: input.toolName,
751
+ riskLevel,
752
+ summary,
753
+ timeoutMs,
754
+ });
755
+ // 決定最終 decision
756
+ let decision;
757
+ let message;
758
+ if (localResult.decision === 'approved' || localResult.decision === 'denied') {
759
+ decision = localResult.decision;
760
+ message = buildLocalDecisionMessage(decision, localResult.source);
761
+ }
762
+ else {
763
+ // timeout:套用 onTimeout 分層設定(與 Cloud 路徑一致)
764
+ const onTimeout = (0, manager_1.toApprovalDecision)(approvalConfig.onTimeout[riskLevel]);
765
+ decision = onTimeout;
766
+ message = buildTimeoutMessage(onTimeout, riskLevel, timeoutMs);
767
+ }
768
+ const decidedAt = new Date().toISOString();
769
+ if (deps.store) {
770
+ deps.store.operations.updateDecision(approvalId, decision, decidedAt);
771
+ }
772
+ const localOutput = {
773
+ approvalId,
774
+ decision,
775
+ riskLevel,
776
+ policyResult,
777
+ message,
778
+ waitedForRemote: false,
779
+ };
780
+ return { content: [{ type: 'text', text: JSON.stringify(localOutput) }] };
781
+ }
782
+ // Fallback: Cloud 未連線 + Local Channel 未啟用 — auto-deny(安全優先)
783
+ logger.warn('hopping.check: auto-deny (no Cloud connection, local channel disabled)', {
784
+ toolName: input.toolName,
785
+ approvalId,
786
+ finalAction: policyResult.finalAction,
787
+ });
788
+ const output = {
789
+ approvalId,
790
+ decision: 'denied',
791
+ riskLevel,
792
+ policyResult,
793
+ message: `Denied by local policy (${policyResult.finalAction}). Remote approval not available — Cloud not connected.`,
794
+ waitedForRemote: false,
795
+ };
796
+ writeCheckOperation(deps, {
797
+ id: approvalId,
798
+ toolName: input.toolName,
799
+ toolInput: input.toolInput,
800
+ filePaths,
801
+ riskLevel,
802
+ decision: 'denied',
803
+ now,
804
+ });
805
+ writeCheckMetadata(deps, { operationId: approvalId, input, now });
806
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
807
+ }
808
+ // ============================================
809
+ // hopping.report Handler
810
+ // ============================================
811
+ async function handleReport(input, deps) {
812
+ const { logger } = deps;
813
+ const operationId = (0, crypto_1.randomUUID)();
814
+ const now = new Date().toISOString();
815
+ if (deps.store) {
816
+ // Write operation record
817
+ deps.store.operations.insert({
818
+ id: operationId,
819
+ approvalId: null,
820
+ toolName: input.toolName,
821
+ toolParams: input.toolParams ? JSON.stringify(input.toolParams) : null,
822
+ filePaths: input.filePaths ? JSON.stringify(input.filePaths) : null,
823
+ riskLevel: null,
824
+ decision: null,
825
+ createdAt: now,
826
+ decidedAt: null,
827
+ toolUseId: null,
828
+ });
829
+ // Write metadata record
830
+ const reportContextTags = {};
831
+ if (input.context?.branch)
832
+ reportContextTags['branch'] = input.context.branch;
833
+ if (input.context?.project)
834
+ reportContextTags['project'] = input.context.project;
835
+ if (input.context?.cwd)
836
+ reportContextTags['cwd'] = input.context.cwd;
837
+ const toolResultSummary = input.result ? input.result.slice(0, 500) : null;
838
+ deps.store.metadata.insert({
839
+ operationId,
840
+ agentType: input.agentType ?? deps.agentType ?? 'generic_mcp',
841
+ sessionId: input.sessionId ?? deps.agentId ?? 'unknown',
842
+ durationMs: input.duration ?? null,
843
+ tokenUsage: null,
844
+ contextTags: Object.keys(reportContextTags).length > 0 ? JSON.stringify(reportContextTags) : null,
845
+ source: 'report',
846
+ resultStatus: input.success === true ? 'success' : input.success === false ? 'failure' : null,
847
+ toolResultSummary,
848
+ createdAt: now,
849
+ });
850
+ logger.info('hopping.report: operation recorded to SQLite', {
851
+ operationId,
852
+ toolName: input.toolName,
853
+ });
854
+ }
855
+ else {
856
+ // Fallback: log only (no store injected)
857
+ logger.info('hopping.report: operation recorded (log only, no store)', {
858
+ operationId,
859
+ toolName: input.toolName,
860
+ success: input.success,
861
+ duration: input.duration,
862
+ filePaths: input.filePaths,
863
+ });
864
+ }
865
+ if (deps.cloudConnector?.isConnected) {
866
+ const agentId = deps.cloudConnector.cloudAgentId ?? deps.agentId ?? 'unknown';
867
+ const sessionId = input.sessionId ?? agentId;
868
+ if (input.context?.taskCompleted) {
869
+ deps.cloudConnector.sendProgressUpdate({
870
+ agentId,
871
+ sessionId,
872
+ message: '🎉 Agent 已完成任務,目前閒置中',
873
+ timestamp: now,
874
+ messageType: 'task_completed',
875
+ });
876
+ }
877
+ else {
878
+ deps.cloudConnector.sendProgressUpdate({
879
+ agentId,
880
+ sessionId,
881
+ message: `📝 ${input.toolName} 操作已記錄`,
882
+ timestamp: now,
883
+ messageType: 'operation_report',
884
+ });
885
+ }
886
+ }
887
+ const output = {
888
+ recorded: true,
889
+ operationId,
890
+ };
891
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
892
+ }
893
+ // ============================================
894
+ // hopping.status Handler
895
+ // ============================================
896
+ async function handleStatus(_input, deps) {
897
+ const { approvalManager, startTime } = deps;
898
+ const todayOperations = deps.store ? deps.store.operations.countToday() : 0;
899
+ const output = {
900
+ activeSessions: 0, // S1: WebSocket sessions
901
+ pendingApprovals: approvalManager.pendingCount,
902
+ todayOperations,
903
+ hubUptime: (Date.now() - startTime) / 1000,
904
+ connectedToCloud: deps.cloudConnector?.isConnected ?? false,
905
+ sessionMemory: deps.sessionMemory?.getAll() ?? [],
906
+ };
907
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
908
+ }
909
+ // ============================================
910
+ // hopping.respond Handler (S2-HUB-APR-002)
911
+ // ============================================
912
+ async function handleRespond(input, deps) {
913
+ const { approvalManager, logger } = deps;
914
+ const { approvalId, decision, reason } = input;
915
+ const approved = decision === 'approved';
916
+ // 1. Check if still in ApprovalManager pending map
917
+ if (approvalManager.isPending(approvalId)) {
918
+ const resolved = approvalManager.resolveApproval(approvalId, approved, reason, 'local_respond');
919
+ if (!resolved) {
920
+ logger.warn('hopping.respond: isPending but resolveApproval returned false', { approvalId });
921
+ }
922
+ // Update SQLite
923
+ if (deps.store) {
924
+ deps.store.operations.updateDecision(approvalId, decision, new Date().toISOString());
925
+ }
926
+ const output = { success: true, source: 'local' };
927
+ logger.info('hopping.respond: resolved pending approval locally', { approvalId, decision });
928
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
929
+ }
930
+ // 2. Not in pending map — check SQLite
931
+ if (!deps.store) {
932
+ const output = { success: false, error: 'Approval request not found' };
933
+ return { content: [{ type: 'text', text: JSON.stringify(output) }], isError: true };
934
+ }
935
+ const record = deps.store.operations.getById(approvalId);
936
+ if (!record) {
937
+ const output = { success: false, error: 'Approval request not found' };
938
+ return { content: [{ type: 'text', text: JSON.stringify(output) }], isError: true };
939
+ }
940
+ // 2a. If decision is still 'pending' → update with local decision
941
+ if (record.decision === 'pending') {
942
+ deps.store.operations.updateDecision(approvalId, decision, new Date().toISOString());
943
+ const output = { success: true, source: 'local' };
944
+ logger.info('hopping.respond: updated pending record in SQLite', { approvalId, decision });
945
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
946
+ }
947
+ // 2b. Already decided (e.g. by Mobile via permission_response)
948
+ const output = {
949
+ success: true,
950
+ decision: record.decision,
951
+ source: 'remote',
952
+ };
953
+ logger.info('hopping.respond: record already decided', {
954
+ approvalId,
955
+ existingDecision: record.decision,
956
+ });
957
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
958
+ }
959
+ // ============================================
960
+ // hopping.pair Handler
961
+ // ============================================
962
+ async function handlePair(input, deps) {
963
+ const { logger, configManager, onCloudConnectorUpdate } = deps;
964
+ if (!configManager || !onCloudConnectorUpdate) {
965
+ return {
966
+ content: [
967
+ {
968
+ type: 'text',
969
+ text: JSON.stringify({
970
+ error: '配對不可用:ConfigManager 未初始化。請確認 Hub 版本是否正確。',
971
+ }),
972
+ },
973
+ ],
974
+ isError: true,
975
+ };
976
+ }
977
+ // 決定 cloudUrl(優先順序:工具參數 > 依賴注入 > 預設值)
978
+ const cloudUrl = input.cloudUrl ?? deps.cloudUrl ?? 'http://localhost:3000';
979
+ // 若已連線 Cloud,先斷開(允許重新配對)
980
+ if (deps.cloudConnector?.isConnected) {
981
+ logger.warn('hopping.pair: Hub 已連線 Cloud,執行重新配對', { cloudUrl });
982
+ await deps.cloudConnector.disconnect();
983
+ }
984
+ // HTTP POST {cloudUrl}/api/v1/agents/pair
985
+ let agentId;
986
+ let agentName;
987
+ let token;
988
+ try {
989
+ const response = await fetch(`${cloudUrl}/api/v1/agents/pair`, {
990
+ method: 'POST',
991
+ headers: { 'Content-Type': 'application/json' },
992
+ body: JSON.stringify({
993
+ pairingCode: input.pairingCode,
994
+ clientInfo: { name: 'HopPing Hub', version: HUB_VERSION },
995
+ }),
996
+ });
997
+ if (!response.ok) {
998
+ const errText = await response.text().catch(() => '');
999
+ return {
1000
+ content: [
1001
+ {
1002
+ type: 'text',
1003
+ text: JSON.stringify({
1004
+ error: `配對失敗:HTTP ${response.status}${errText ? ` — ${errText}` : ''}`,
1005
+ }),
1006
+ },
1007
+ ],
1008
+ isError: true,
1009
+ };
1010
+ }
1011
+ const raw = await response.json();
1012
+ const parsed = schemas_1.PairApiResponseSchema.safeParse(raw);
1013
+ if (!parsed.success) {
1014
+ const details = parsed.error.issues.map((i) => `${i.path.join('.')}: ${i.message}`);
1015
+ return {
1016
+ content: [
1017
+ {
1018
+ type: 'text',
1019
+ text: JSON.stringify({
1020
+ error: `配對回應格式異常:${details.join('; ')}`,
1021
+ }),
1022
+ },
1023
+ ],
1024
+ isError: true,
1025
+ };
1026
+ }
1027
+ agentId = parsed.data.agentId;
1028
+ agentName = parsed.data.name;
1029
+ token = parsed.data.token;
1030
+ }
1031
+ catch (err) {
1032
+ const errMsg = err instanceof Error ? err.message : String(err);
1033
+ return {
1034
+ content: [
1035
+ {
1036
+ type: 'text',
1037
+ text: JSON.stringify({ error: `配對請求失敗:${errMsg}` }),
1038
+ },
1039
+ ],
1040
+ isError: true,
1041
+ };
1042
+ }
1043
+ // 儲存 token 到本地設定(~/.hopping/config.json,chmod 0o600)
1044
+ await configManager.saveAfterPairing(agentId, token, cloudUrl);
1045
+ logger.info('hopping.pair: 配對成功,token 已儲存至本地設定', { agentId, cloudUrl });
1046
+ // 建立新 CloudConnector 並連線
1047
+ const connectorOptions = {
1048
+ cloudUrl,
1049
+ agentToken: token,
1050
+ logger,
1051
+ approvalManager: deps.approvalManager,
1052
+ store: deps.store,
1053
+ };
1054
+ const newConnector = new cloud_1.CloudConnector(connectorOptions);
1055
+ // 更新 index.ts 的 cloudConnector 參考(透過 callback)
1056
+ onCloudConnectorUpdate(newConnector);
1057
+ // 非阻塞連線(超時後仍以本地模式運行,socket.io 自動重連)
1058
+ await newConnector.connect();
1059
+ const connectedToCloud = newConnector.isConnected;
1060
+ const output = {
1061
+ paired: true,
1062
+ agentId,
1063
+ agentName,
1064
+ connectedToCloud,
1065
+ message: connectedToCloud
1066
+ ? `已成功配對並連線到 Cloud(${agentName})`
1067
+ : `已配對成功,但 Cloud 初始連線超時。Hub 以本地模式運行,將在背景自動重連。`,
1068
+ };
1069
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
1070
+ }
1071
+ // ============================================
1072
+ // hopping.mode Handler (REQ-HUB-041)
1073
+ // ============================================
1074
+ async function handleMode(input, deps) {
1075
+ const { logger } = deps;
1076
+ const previousMode = askMode;
1077
+ askMode = input.mode;
1078
+ logger.info('hopping.mode: mode switched', { previousMode, newMode: askMode });
1079
+ const output = {
1080
+ mode: askMode,
1081
+ message: askMode === 'away'
1082
+ ? '已切換為 Away 模式。hopping.ask 的問題將送到手機,等待用戶回答。'
1083
+ : '已切換為 Local 模式。hopping.ask 會立即回傳,請直接詢問使用者。',
1084
+ };
1085
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
1086
+ }
1087
+ // ============================================
1088
+ // hopping.ask Handler (REQ-HUB-040, mode-aware)
1089
+ // ============================================
1090
+ /** 取得 AskConfig,失敗時回傳預設值 */
1091
+ async function getAskConfigSafe(deps) {
1092
+ if (!deps.configManager)
1093
+ return { ...manager_1.DEFAULT_ASK_CONFIG };
1094
+ try {
1095
+ return await deps.configManager.getAskConfig();
1096
+ }
1097
+ catch {
1098
+ return { ...manager_1.DEFAULT_ASK_CONFIG };
1099
+ }
1100
+ }
1101
+ async function handleAsk(input, deps) {
1102
+ const { logger } = deps;
1103
+ const questionId = (0, crypto_1.randomUUID)();
1104
+ const now = new Date().toISOString();
1105
+ // ── Local 模式:立即回傳 ──
1106
+ if (askMode === 'local') {
1107
+ logger.info('hopping.ask: local mode, returning ask_locally', { questionId });
1108
+ const output = {
1109
+ questionId,
1110
+ status: 'ask_locally',
1111
+ answer: '',
1112
+ respondedBy: 'local',
1113
+ message: '目前為 Local 模式,請直接詢問使用者',
1114
+ };
1115
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
1116
+ }
1117
+ // ── Away 模式:送 Mobile 並阻塞等回覆 ──
1118
+ const { cloudConnector } = deps;
1119
+ const askConfig = await getAskConfigSafe(deps);
1120
+ // 寫入 SQLite(promptType='question',responseType=input.responseType)
1121
+ if (deps.store) {
1122
+ deps.store.operations.insert({
1123
+ id: questionId,
1124
+ approvalId: null,
1125
+ toolName: 'hopping.ask',
1126
+ toolParams: JSON.stringify({ question: input.question, responseType: input.responseType }),
1127
+ filePaths: null,
1128
+ riskLevel: null,
1129
+ decision: null,
1130
+ createdAt: now,
1131
+ decidedAt: null,
1132
+ toolUseId: null,
1133
+ promptType: 'question',
1134
+ responseType: input.responseType,
1135
+ });
1136
+ }
1137
+ if (cloudConnector?.isConnected) {
1138
+ // Cloud 已連線:送 permission_prompt(promptType='question')
1139
+ const agentId = cloudConnector.cloudAgentId ?? deps.agentId ?? 'unknown';
1140
+ cloudConnector.sendPermissionPrompt({
1141
+ id: questionId,
1142
+ agentId,
1143
+ toolName: 'hopping.ask',
1144
+ riskLevel: 'low',
1145
+ summary: input.question,
1146
+ policyAction: 'ASK',
1147
+ sessionId: agentId,
1148
+ timestamp: now,
1149
+ promptType: 'question',
1150
+ responseType: input.responseType,
1151
+ options: input.options,
1152
+ placeholder: input.placeholder,
1153
+ taskContext: input.context
1154
+ ? {
1155
+ taskDescription: input.context.taskDescription,
1156
+ reasoning: input.context.reasoning,
1157
+ }
1158
+ : undefined,
1159
+ });
1160
+ // 通知 Mobile:問題已送出
1161
+ cloudConnector.sendProgressUpdate({
1162
+ agentId,
1163
+ sessionId: agentId,
1164
+ message: `❓ 等待用戶回答:${input.question}`,
1165
+ timestamp: now,
1166
+ messageType: 'progress',
1167
+ });
1168
+ }
1169
+ // 等待用戶回應(askConfig timeout)
1170
+ const timeoutMs = askConfig.timeoutEnabled ? askConfig.timeoutMs : Infinity;
1171
+ const result = await deps.approvalManager.waitForApproval(questionId, timeoutMs);
1172
+ if (result.timedOut) {
1173
+ logger.info('hopping.ask: away mode timeout', { questionId });
1174
+ // 更新 SQLite
1175
+ if (deps.store) {
1176
+ deps.store.operations.updateDecision(questionId, 'timeout', new Date().toISOString());
1177
+ }
1178
+ const output = {
1179
+ questionId,
1180
+ status: 'timeout',
1181
+ answer: '',
1182
+ respondedBy: 'local',
1183
+ message: '用戶未回應,請自行判斷或稍後重問',
1184
+ };
1185
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
1186
+ }
1187
+ // 收到回應
1188
+ logger.info('hopping.ask: away mode answered', {
1189
+ questionId,
1190
+ selectedOption: result.selectedOption,
1191
+ freetext: result.freetext,
1192
+ });
1193
+ // 更新 SQLite
1194
+ const decidedAt = new Date().toISOString();
1195
+ if (deps.store) {
1196
+ deps.store.operations.updateDecision(questionId, 'answered', decidedAt);
1197
+ if (result.selectedOption || result.freetext || result.customText) {
1198
+ deps.store.operations.updateResponseData(questionId, JSON.stringify({
1199
+ selectedOption: result.selectedOption,
1200
+ freetext: result.freetext,
1201
+ customText: result.customText,
1202
+ }));
1203
+ }
1204
+ }
1205
+ // 決定 answer:choice → selectedOption,freetext → freetext
1206
+ const answer = result.selectedOption ?? result.freetext ?? '';
1207
+ const output = {
1208
+ questionId,
1209
+ status: 'answered',
1210
+ answer,
1211
+ customText: result.customText,
1212
+ respondedBy: result.source === 'local_respond' || result.source === 'local_dialog' ? 'local' : 'mobile',
1213
+ message: '用戶已回答',
1214
+ };
1215
+ return { content: [{ type: 'text', text: JSON.stringify(output) }] };
1216
+ }
1217
+ //# sourceMappingURL=tools.js.map