@xmanrui/dsh-im 4.9.1 → 4.11.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 (72) hide show
  1. package/PROACTIVE_DELIVERY.en.md +23 -1
  2. package/PROACTIVE_DELIVERY.md +23 -1
  3. package/README.en.md +5 -1
  4. package/README.md +6 -2
  5. package/lib/client.js +2077 -673
  6. package/lib/index.js +277 -272
  7. package/package.json +13 -1
  8. package/plugin-src/client/channel-card-meta.js +9 -1
  9. package/plugin-src/client/channels/dingtalk/api.js +4 -0
  10. package/plugin-src/client/channels/dingtalk/index.js +25 -2
  11. package/plugin-src/client/channels/feishu/api.js +6 -0
  12. package/plugin-src/client/channels/feishu/group-settings.js +581 -0
  13. package/plugin-src/client/channels/feishu/index.js +26 -150
  14. package/plugin-src/client/channels/qq/api.js +4 -0
  15. package/plugin-src/client/channels/qq/index.js +25 -2
  16. package/plugin-src/client/channels/shared/token-api.js +4 -0
  17. package/plugin-src/client/channels/shared/token-channel.js +25 -3
  18. package/plugin-src/client/channels/wecom/api.js +4 -0
  19. package/plugin-src/client/channels/wecom/index.js +25 -2
  20. package/plugin-src/client/channels/weixin/api.js +4 -0
  21. package/plugin-src/client/channels/weixin/index.js +25 -2
  22. package/plugin-src/client/channels/whatsapp/api.js +4 -0
  23. package/plugin-src/client/channels/whatsapp/index.js +24 -2
  24. package/plugin-src/client/delivery-settings.js +73 -5
  25. package/plugin-src/client/global-settings.js +341 -0
  26. package/plugin-src/client/i18n.js +49 -0
  27. package/plugin-src/client/index.js +47 -6
  28. package/plugin-src/client/model-setting.js +104 -0
  29. package/plugin-src/client/styles.js +99 -0
  30. package/plugin-src/host/channels/dingtalk/production.mjs +15 -0
  31. package/plugin-src/host/channels/dingtalk/rpc.mjs +11 -0
  32. package/plugin-src/host/channels/feishu/production.mjs +17 -0
  33. package/plugin-src/host/channels/feishu/rpc.mjs +32 -0
  34. package/plugin-src/host/channels/qq/production.mjs +15 -0
  35. package/plugin-src/host/channels/qq/rpc.mjs +11 -0
  36. package/plugin-src/host/channels/shared/model-setting-rpc.mjs +19 -0
  37. package/plugin-src/host/channels/shared/production.mjs +15 -0
  38. package/plugin-src/host/channels/shared/rpc.mjs +9 -0
  39. package/plugin-src/host/channels/shared/workspace-rpc.mjs +2 -0
  40. package/plugin-src/host/channels/slack/production.mjs +15 -0
  41. package/plugin-src/host/channels/slack/rpc.mjs +9 -0
  42. package/plugin-src/host/channels/wecom/production.mjs +15 -0
  43. package/plugin-src/host/channels/wecom/rpc.mjs +11 -0
  44. package/plugin-src/host/channels/weixin/production.mjs +15 -0
  45. package/plugin-src/host/channels/weixin/rpc.mjs +11 -0
  46. package/plugin-src/host/channels/whatsapp/production.mjs +15 -0
  47. package/plugin-src/host/channels/whatsapp/rpc.mjs +11 -0
  48. package/plugin-src/host/delivery-adapter.mjs +142 -3
  49. package/plugin-src/host/delivery-rpc.mjs +12 -0
  50. package/plugin-src/host/delivery-service.mjs +125 -3
  51. package/plugin-src/host/delivery-suggestions.mjs +46 -0
  52. package/plugin-src/host/harness-session-coordinator.mjs +32 -10
  53. package/plugin-src/host/inbound-ttl-rpc.mjs +83 -0
  54. package/plugin-src/host/inbound-ttl-runtime.mjs +85 -0
  55. package/plugin-src/host/inbound-ttl-service.mjs +179 -0
  56. package/plugin-src/host/index.mjs +26 -1
  57. package/plugin-src/host/modern-harness-api.mjs +58 -2
  58. package/plugin-src/host/session-sync-coordinator.mjs +217 -0
  59. package/scripts/verify-package.mjs +12 -6
  60. package/src/channels/feishu/bridge.mjs +164 -4
  61. package/src/channels/feishu/feishu-channel.mjs +45 -10
  62. package/src/channels/feishu/feishu-runtime.mjs +9 -0
  63. package/src/channels/feishu/message-utils.mjs +13 -0
  64. package/src/channels/feishu/multi-bot-controller.mjs +16 -0
  65. package/src/channels/feishu/plugin-config-store.mjs +1 -0
  66. package/src/channels/feishu/state-store.mjs +25 -0
  67. package/src/channels/shared/bot-workspace-store.mjs +291 -38
  68. package/src/channels/shared/harness-client.mjs +124 -43
  69. package/src/channels/shared/inbound-file.mjs +117 -2
  70. package/src/channels/shared/inbound-ttl-store.mjs +110 -0
  71. package/src/channels/shared/inbound-ttl.mjs +22 -0
  72. package/src/channels/shared/model-setting.mjs +110 -0
@@ -7,6 +7,7 @@ const DRAFT_TARGET_ID = '__test__';
7
7
 
8
8
  const ADAPTER_METHODS = Object.freeze([
9
9
  'ownsBot',
10
+ 'listBots',
10
11
  'listTargets',
11
12
  'listSuggestions',
12
13
  'createTarget',
@@ -24,9 +25,16 @@ const DELIVERY_ERROR_CODES = new Set([
24
25
  'bot-not-connected',
25
26
  'target-rejected',
26
27
  'delivery-failed',
28
+ 'session-sync-unavailable',
27
29
  'cancelled',
28
30
  ]);
29
31
 
32
+ const SESSION_SYNC_METHODS = Object.freeze([
33
+ 'setSessionSync',
34
+ 'listSessionSyncTargets',
35
+ 'sendSessionSyncText',
36
+ ]);
37
+
30
38
  function deliveryError(code, message = code, options) {
31
39
  const error = new Error(message, options);
32
40
  error.code = code;
@@ -92,8 +100,29 @@ function validateAdapter(adapter) {
92
100
  return adapter;
93
101
  }
94
102
 
103
+ function sessionSyncState(value, available) {
104
+ const enabled = value?.enabled === true;
105
+ if (!available) return { enabled, state: 'unavailable' };
106
+ const state = value?.state;
107
+ if (['off', 'active', 'waiting', 'unavailable'].includes(state)) {
108
+ return { enabled, state };
109
+ }
110
+ return { enabled: false, state: 'unavailable' };
111
+ }
112
+
95
113
  export class DeliveryService {
96
114
  #adapters = new Map();
115
+ #unavailableSessionSyncChannels;
116
+
117
+ constructor({ unavailableSessionSyncChannels = [] } = {}) {
118
+ if (!Array.isArray(unavailableSessionSyncChannels)
119
+ || unavailableSessionSyncChannels.some((channel) => (
120
+ typeof channel !== 'string' || !CHANNEL_PATTERN.test(channel)
121
+ ))) {
122
+ throw new TypeError('unavailableSessionSyncChannels must contain valid channel ids');
123
+ }
124
+ this.#unavailableSessionSyncChannels = new Set(unavailableSessionSyncChannels);
125
+ }
97
126
 
98
127
  registerAdapter(value) {
99
128
  const adapter = validateAdapter(value);
@@ -112,12 +141,34 @@ export class DeliveryService {
112
141
  try {
113
142
  const targets = await adapter.listTargets(id);
114
143
  if (!Array.isArray(targets)) throw new TypeError('Adapter returned invalid targets');
115
- return { botId: id, channel: adapter.channel, targets };
144
+ const available = this.#supportsSessionSync(adapter);
145
+ return {
146
+ botId: id,
147
+ channel: adapter.channel,
148
+ targets: targets.map((target) => ({
149
+ ...target,
150
+ sessionSync: sessionSyncState(target?.sessionSync, available),
151
+ })),
152
+ };
116
153
  } catch (error) {
117
154
  throw publicOperationError(error);
118
155
  }
119
156
  }
120
157
 
158
+ async listBots() {
159
+ const bots = [];
160
+ for (const { adapter } of this.#adapters.values()) {
161
+ try {
162
+ const ids = await adapter.listBots();
163
+ if (!Array.isArray(ids)) throw new TypeError('Adapter returned invalid bots');
164
+ for (const botId of ids) bots.push({ botId: botIdOf(botId), channel: adapter.channel });
165
+ } catch (error) {
166
+ throw publicOperationError(error);
167
+ }
168
+ }
169
+ return bots;
170
+ }
171
+
121
172
  async listSuggestions(botId) {
122
173
  const id = botIdOf(botId);
123
174
  const adapter = await this.#adapterFor(id);
@@ -173,6 +224,72 @@ export class DeliveryService {
173
224
  }
174
225
  }
175
226
 
227
+ async setSessionSync(botId, targetId, enabled) {
228
+ const id = botIdOf(botId);
229
+ const targetKey = targetIdOf(targetId);
230
+ if (typeof enabled !== 'boolean') throw deliveryError('bad-request', 'Invalid enabled state');
231
+ const adapter = await this.#adapterFor(id);
232
+ const hasMethods = SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
233
+ if (!hasMethods || (enabled && !this.#supportsSessionSync(adapter))) {
234
+ throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
235
+ }
236
+ try {
237
+ return await adapter.setSessionSync(id, targetKey, enabled);
238
+ } catch (error) {
239
+ throw publicOperationError(error);
240
+ }
241
+ }
242
+
243
+ async listSessionSyncTargets(sessionId) {
244
+ if (typeof sessionId !== 'string' || !sessionId) {
245
+ throw deliveryError('bad-request', 'Invalid Session id');
246
+ }
247
+ const targets = [];
248
+ for (const { adapter } of this.#adapters.values()) {
249
+ if (!this.#supportsSessionSync(adapter)) continue;
250
+ try {
251
+ const listed = await adapter.listSessionSyncTargets(sessionId);
252
+ if (!Array.isArray(listed)) throw new TypeError('Adapter returned invalid sync targets');
253
+ for (const target of listed) {
254
+ targets.push({
255
+ channel: adapter.channel,
256
+ botId: botIdOf(target?.botId),
257
+ targetId: targetIdOf(target?.targetId),
258
+ });
259
+ }
260
+ } catch (error) {
261
+ console.warn(
262
+ `[dsh-im] ignored ${adapter.channel} Session sync target lookup failure`
263
+ + ` (${error?.code ?? error?.name ?? 'unknown-error'})`,
264
+ );
265
+ }
266
+ }
267
+ return targets;
268
+ }
269
+
270
+ async sendSessionSyncText(botId, targetId, sessionId, text, { signal } = {}) {
271
+ const id = botIdOf(botId);
272
+ const targetKey = targetIdOf(targetId);
273
+ if (typeof sessionId !== 'string' || !sessionId
274
+ || typeof text !== 'string' || !text.trim()) {
275
+ throw deliveryError('bad-request', 'Invalid session sync delivery');
276
+ }
277
+ cancellation(signal);
278
+ const adapter = await this.#adapterFor(id);
279
+ if (!this.#supportsSessionSync(adapter)) {
280
+ throw deliveryError('session-sync-unavailable', 'Session sync is unavailable');
281
+ }
282
+ try {
283
+ await adapter.sendSessionSyncText(id, targetKey, sessionId, text, { signal });
284
+ return { sent: true };
285
+ } catch (error) {
286
+ if (signal?.aborted || error?.name === 'AbortError' || error?.code === 'ABORT_ERR') {
287
+ throw deliveryError('cancelled', 'Request cancelled', { cause: error });
288
+ }
289
+ throw publicOperationError(error);
290
+ }
291
+ }
292
+
176
293
  async send(botId, targetIdOrDraft, text, { signal } = {}) {
177
294
  const id = botIdOf(botId);
178
295
  const targetKey = typeof targetIdOrDraft === 'string'
@@ -217,8 +334,13 @@ export class DeliveryService {
217
334
  }
218
335
  throw deliveryError('unknown-bot', 'Unknown bot');
219
336
  }
337
+
338
+ #supportsSessionSync(adapter) {
339
+ return !this.#unavailableSessionSyncChannels.has(adapter.channel)
340
+ && SESSION_SYNC_METHODS.every((method) => typeof adapter[method] === 'function');
341
+ }
220
342
  }
221
343
 
222
- export function createDeliveryService() {
223
- return new DeliveryService();
344
+ export function createDeliveryService(options) {
345
+ return new DeliveryService(options);
224
346
  }
@@ -115,6 +115,52 @@ export function deliverySuggestionFromConversationKey(channel, key) {
115
115
  }
116
116
  }
117
117
 
118
+ /** Convert only a persisted private-chat key into its stable delivery route. */
119
+ export function privateDeliverySuggestionFromConversationKey(channel, key) {
120
+ const separator = typeof key === 'string' ? key.indexOf(':') : -1;
121
+ const prefix = separator > 0 ? key.slice(0, separator) : '';
122
+ const privatePrefix = {
123
+ weixin: 'p2p',
124
+ feishu: 'p2p',
125
+ dingtalk: 'p2p',
126
+ wecom: 'direct',
127
+ qq: 'c2c',
128
+ slack: 'direct',
129
+ telegram: 'direct',
130
+ discord: 'direct',
131
+ whatsapp: 'direct',
132
+ }[channel];
133
+ if (!privatePrefix || prefix !== privatePrefix) return null;
134
+ return deliverySuggestionFromConversationKey(channel, key);
135
+ }
136
+
137
+ function routeIdentity(value) {
138
+ if (!isRecord(value?.route) || typeof value.kind !== 'string') return null;
139
+ return JSON.stringify([
140
+ value.kind,
141
+ Object.keys(value.route).sort().map((key) => [key, value.route[key]]),
142
+ ]);
143
+ }
144
+
145
+ /** Check that one saved private conversation key still identifies this target route. */
146
+ export function privateConversationKeyMatchesTarget(channel, key, target) {
147
+ const suggestion = privateDeliverySuggestionFromConversationKey(channel, key);
148
+ const suggestionIdentity = routeIdentity(suggestion);
149
+ return suggestionIdentity !== null && suggestionIdentity === routeIdentity(target);
150
+ }
151
+
152
+ /** Resolve exactly one current private conversation for a normalized delivery target. */
153
+ export function resolvePrivateConversationKey(channel, sessions, target) {
154
+ if (!CHANNELS.has(channel) || !isRecord(sessions)) return null;
155
+ const matches = [];
156
+ for (const [key, sessionId] of Object.entries(sessions)) {
157
+ if (typeof sessionId !== 'string' || !sessionId) continue;
158
+ if (privateConversationKeyMatchesTarget(channel, key, target)) matches.push(key);
159
+ if (matches.length > 1) return null;
160
+ }
161
+ return matches[0] ?? null;
162
+ }
163
+
118
164
  /**
119
165
  * Extract and de-duplicate stable delivery routes from a persisted sessions map.
120
166
  * Session ids and every other state field are intentionally ignored.
@@ -22,7 +22,9 @@ function agentsFromContext(ctx) {
22
22
 
23
23
  function currentOwnedTurn(agent, expectedTurn, promptRpcId) {
24
24
  if (agent?.status !== 'running') return false;
25
- const events = agent?.session?.events;
25
+ const events = typeof agent?.session?.snapshotEvents === 'function'
26
+ ? agent.session.snapshotEvents()
27
+ : agent?.session?.events;
26
28
  if (!Array.isArray(events)) return false;
27
29
 
28
30
  let openTurn = null;
@@ -53,12 +55,12 @@ function deepFreeze(value) {
53
55
  return Object.freeze(value);
54
56
  }
55
57
 
56
- function steeringMessage(text) {
58
+ function steeringMessage(text, rpcId) {
57
59
  return deepFreeze({
58
60
  id: randomUUID(),
59
61
  role: 'user',
60
62
  content: [{ type: 'text', text }],
61
- source: { kind: 'user' },
63
+ source: { kind: 'user', rpcId },
62
64
  });
63
65
  }
64
66
 
@@ -69,7 +71,7 @@ function agentBusyError(cause) {
69
71
  }
70
72
 
71
73
  function createControlExecutor(agents) {
72
- return ({ sessionId, expectedTurn, promptRpcId, action, text }) => {
74
+ return ({ sessionId, expectedTurn, promptRpcId, inputRpcId, action, text }) => {
73
75
  const agent = agents.get(sessionId);
74
76
  // An unattached Session cannot be coordinated in-process. Let the client
75
77
  // retain its legacy HTTP path for deployments where attachment is lazy.
@@ -81,10 +83,11 @@ function createControlExecutor(agents) {
81
83
  return true;
82
84
  }
83
85
  if (action === 'steer') {
84
- if (typeof text !== 'string' || !text.trim()) return false;
86
+ if (typeof text !== 'string' || !text.trim()
87
+ || typeof inputRpcId !== 'string' || !inputRpcId) return false;
85
88
  // inject() never wakes an idle driver. Because validation and injection
86
89
  // share one JS tick, this context can only target this live turn's next step.
87
- agent.inject(steeringMessage(text));
90
+ agent.inject(steeringMessage(text, inputRpcId));
88
91
  return true;
89
92
  }
90
93
  throw new TypeError(`Unsupported Harness control action: ${String(action)}`);
@@ -113,7 +116,7 @@ function createSessionMaintenanceExecutor(agents) {
113
116
  };
114
117
  }
115
118
 
116
- function createFileIngressExecutor(agents) {
119
+ function createFileIngressExecutor(agents, { inboundTtlService } = {}) {
117
120
  return ({ sessionId, workspace, files, signal }) => {
118
121
  const agent = agents.get(sessionId);
119
122
  const attachedWorkspace = agent?.session?.header?.cwd;
@@ -126,7 +129,19 @@ function createFileIngressExecutor(agents) {
126
129
  'The Harness Session workspace is unavailable for inbound files.',
127
130
  );
128
131
  }
129
- return stageInboundFiles({ files }, { workspace: exactWorkspace, signal });
132
+ const staged = stageInboundFiles({ files }, {
133
+ workspace: exactWorkspace,
134
+ signal,
135
+ ...(inboundTtlService ? { retention: inboundTtlService.stagingRetention() } : {}),
136
+ // Registration at creation time closes the mid-staging sweep window;
137
+ // trackStaged later re-registers idempotently and owns the release.
138
+ ...(typeof inboundTtlService?.trackDirectory === 'function'
139
+ ? { onStagedDirectory: (directory) => inboundTtlService.trackDirectory(directory) }
140
+ : {}),
141
+ });
142
+ return inboundTtlService
143
+ ? staged.then((result) => (result ? inboundTtlService.trackStaged(result) : result))
144
+ : staged;
130
145
  };
131
146
  }
132
147
 
@@ -136,7 +151,9 @@ function createFileIngressExecutor(agents) {
136
151
  * when an Agent is attached, its live Session header remains authoritative.
137
152
  */
138
153
  export function createHarnessSessionExecutors(ctx, provided = {}) {
139
- const { controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor } = provided;
154
+ const {
155
+ controlExecutor, sessionMaintenanceExecutor, fileIngressExecutor, inboundTtlService,
156
+ } = provided;
140
157
  if (controlExecutor !== undefined && typeof controlExecutor !== 'function') {
141
158
  throw new TypeError('controlExecutor must be a function');
142
159
  }
@@ -147,6 +164,11 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
147
164
  if (fileIngressExecutor !== undefined && typeof fileIngressExecutor !== 'function') {
148
165
  throw new TypeError('fileIngressExecutor must be a function');
149
166
  }
167
+ if (inboundTtlService !== undefined
168
+ && (typeof inboundTtlService?.stagingRetention !== 'function'
169
+ || typeof inboundTtlService?.trackStaged !== 'function')) {
170
+ throw new TypeError('inboundTtlService must expose stagingRetention() and trackStaged()');
171
+ }
150
172
 
151
173
  const agents = controlExecutor && sessionMaintenanceExecutor && fileIngressExecutor
152
174
  ? undefined
@@ -156,6 +178,6 @@ export function createHarnessSessionExecutors(ctx, provided = {}) {
156
178
  sessionMaintenanceExecutor: sessionMaintenanceExecutor
157
179
  ?? (agents ? createSessionMaintenanceExecutor(agents) : undefined),
158
180
  fileIngressExecutor: fileIngressExecutor
159
- ?? createFileIngressExecutor(agents ?? { get: () => undefined }),
181
+ ?? createFileIngressExecutor(agents ?? { get: () => undefined }, { inboundTtlService }),
160
182
  };
161
183
  }
@@ -0,0 +1,83 @@
1
+ import { normalizeInboundTtlHours } from '../../src/channels/shared/inbound-ttl.mjs';
2
+ import { getInboundTtlRuntime } from './inbound-ttl-runtime.mjs';
3
+
4
+ export const INBOUND_TTL_RPC_CHANNEL = '/dsh-im-settings';
5
+ export const INBOUND_TTL_ENDPOINTS = Object.freeze({
6
+ get: 'settings.inbound-ttl.get',
7
+ set: 'settings.inbound-ttl.set',
8
+ sweep: 'settings.inbound-ttl.sweep',
9
+ });
10
+
11
+ const ENDPOINTS = new Set(Object.values(INBOUND_TTL_ENDPOINTS));
12
+
13
+ export function validInboundTtlPayload(endpoint, payload) {
14
+ if (!ENDPOINTS.has(endpoint)) return false;
15
+ if (payload === null || typeof payload !== 'object' || Array.isArray(payload)) return false;
16
+ const keys = Object.keys(payload);
17
+ if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
18
+ return keys.length === 1 && keys[0] === 'ttlHours'
19
+ && normalizeInboundTtlHours(payload.ttlHours) !== null;
20
+ }
21
+ return keys.length === 0;
22
+ }
23
+
24
+ export function createInboundTtlRpcHandler({ store, service, logger = null } = {}) {
25
+ if (!store || typeof store.getTtlHours !== 'function'
26
+ || typeof store.setTtlHours !== 'function'
27
+ || !service || typeof service.sweepNow !== 'function') {
28
+ throw new TypeError('createInboundTtlRpcHandler requires an inbound TTL store and service');
29
+ }
30
+ return async (endpoint, payload, signal) => {
31
+ if (!validInboundTtlPayload(endpoint, payload)) {
32
+ return { ok: false, error: { code: 'bad-request', message: 'Invalid inbound TTL request.' } };
33
+ }
34
+ if (signal?.aborted) {
35
+ return { ok: false, error: { code: 'cancelled', message: 'Request cancelled.' } };
36
+ }
37
+ try {
38
+ if (endpoint === INBOUND_TTL_ENDPOINTS.get) {
39
+ return { ok: true, value: { ttlHours: store.getTtlHours() } };
40
+ }
41
+ if (endpoint === INBOUND_TTL_ENDPOINTS.set) {
42
+ const ttlHours = await store.setTtlHours(payload.ttlHours);
43
+ // Re-arm the sweep interval so the saved value gets a full window
44
+ // before its first scheduled enforcement.
45
+ service.resetSweepSchedule?.();
46
+ return { ok: true, value: { ttlHours } };
47
+ }
48
+ const summary = await service.sweepNow();
49
+ // The UI shows no result text, so the host log is the only place a
50
+ // manual sweep can be diagnosed (TTL applied, coverage, deletions).
51
+ logger?.info?.(
52
+ `[dsh-im] manual inbound attachment sweep: ${summary.deletedDirectories} deleted`
53
+ + ` across ${summary.sweptWorkspaces} workspaces (ttl ${store.getTtlHours()}h)`,
54
+ );
55
+ return { ok: true, value: summary };
56
+ } catch (error) {
57
+ if (error?.code === 'inbound-ttl-invalid') {
58
+ return {
59
+ ok: false,
60
+ error: {
61
+ code: 'inbound-ttl-invalid',
62
+ message: '附件保留时长无效:请输入 -1、0 或 1-8760 之间的整数小时数。',
63
+ },
64
+ };
65
+ }
66
+ return { ok: false, error: { code: 'inbound-ttl-unavailable', message: 'inbound-ttl-unavailable' } };
67
+ }
68
+ };
69
+ }
70
+
71
+ export function installInboundTtlRpc(ctx, options = {}) {
72
+ if (!ctx?.connection?.rpc || typeof ctx.connection.rpc.handle !== 'function') {
73
+ throw new TypeError('DSH Host Connection RPC is required');
74
+ }
75
+ const runtime = options.runtime ?? getInboundTtlRuntime(ctx, options.config);
76
+ const logger = typeof ctx?.logger === 'function'
77
+ ? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? null);
78
+ return ctx.connection.rpc.handle(
79
+ INBOUND_TTL_RPC_CHANNEL,
80
+ createInboundTtlRpcHandler({ ...runtime, logger }),
81
+ { authority: 'loopback' },
82
+ );
83
+ }
@@ -0,0 +1,85 @@
1
+ import { homedir } from 'node:os';
2
+ import { join, resolve } from 'node:path';
3
+
4
+ import { InboundTtlStore } from '../../src/channels/shared/inbound-ttl-store.mjs';
5
+ import { createInboundTtlService } from './inbound-ttl-service.mjs';
6
+
7
+ const runtimes = new Map();
8
+
9
+ /**
10
+ * Resolve the global settings file shared by every channel. The dshHome
11
+ * resolution mirrors pluginPaths: config.dshHome, then DSH_HOME, then the
12
+ * user's home directory. Channel-specific dataDir values never apply.
13
+ */
14
+ export function inboundTtlSettingsPath(config = {}) {
15
+ const dshHome = resolve(config.dshHome ?? process.env.DSH_HOME ?? join(homedir(), '.dsh'));
16
+ return resolve(dshHome, 'integrations', 'dsh-im', 'settings.json');
17
+ }
18
+
19
+ /**
20
+ * Per-process inbound TTL runtime, cached by resolved settings path so every
21
+ * channel shares one store, one staged registry, and one sweep timer. The
22
+ * entry is dropped (and the sweeper stopped) when the owning context is
23
+ * disposed, letting a later activation rebuild from disk.
24
+ */
25
+ export function getInboundTtlRuntime(ctx, config = {}) {
26
+ const settingsPath = inboundTtlSettingsPath(config);
27
+ const existing = runtimes.get(settingsPath);
28
+ if (existing) return existing.runtime;
29
+ const logger = typeof ctx?.logger === 'function'
30
+ ? ctx.logger('dsh-im:inbound-ttl') : (ctx?.logger ?? console);
31
+ const store = new InboundTtlStore(settingsPath);
32
+ const service = createInboundTtlService({ store, logger });
33
+ const runtime = Object.freeze({ store, service });
34
+ const entry = { runtime };
35
+ runtimes.set(settingsPath, entry);
36
+ // Load persisted settings before the first sweep so a keep-forever TTL can
37
+ // never be overtaken by the in-memory default. An unreadable store keeps
38
+ // sweeping disabled rather than deleting files of unknown intent.
39
+ void store.load().then(
40
+ () => service.start(),
41
+ (error) => logger.error?.('[dsh-im] unable to load inbound attachment settings; sweeping stays disabled', error),
42
+ );
43
+ if (typeof ctx?.effect === 'function') {
44
+ ctx.effect(() => () => {
45
+ if (runtimes.get(settingsPath) !== entry) return;
46
+ runtimes.delete(settingsPath);
47
+ service.stop();
48
+ }, 'dsh-im: stop inbound attachment TTL sweeper');
49
+ }
50
+ return runtime;
51
+ }
52
+
53
+ /**
54
+ * Advertise one channel's bot workspaces to the global sweeper. The provider
55
+ * is consulted at sweep time, so bots added or removed later are always
56
+ * current. The default workspace is always included.
57
+ */
58
+ export function registerInboundTtlWorkspaces(ctx, service, {
59
+ workspaces,
60
+ configStore,
61
+ defaultWorkspace,
62
+ botIdFrom = (bot) => bot?.botId,
63
+ } = {}) {
64
+ if (!service || typeof service.registerWorkspaceProvider !== 'function') return undefined;
65
+ if (!workspaces || typeof workspaces.workspaceFor !== 'function'
66
+ || typeof defaultWorkspace !== 'string' || !defaultWorkspace) {
67
+ return undefined;
68
+ }
69
+ const unregister = service.registerWorkspaceProvider(() => {
70
+ const bots = typeof configStore?.list === 'function' ? configStore.list() : [];
71
+ const paths = [defaultWorkspace];
72
+ for (const bot of Array.isArray(bots) ? bots : []) {
73
+ try {
74
+ paths.push(workspaces.workspaceFor(botIdFrom(bot)));
75
+ } catch {
76
+ // A malformed config entry must not hide the remaining workspaces.
77
+ }
78
+ }
79
+ return paths;
80
+ });
81
+ if (typeof ctx?.effect === 'function') {
82
+ ctx.effect(() => unregister, 'dsh-im: unregister inbound TTL workspaces');
83
+ }
84
+ return unregister;
85
+ }
@@ -0,0 +1,179 @@
1
+ import { realpathSync } from 'node:fs';
2
+ import { resolve } from 'node:path';
3
+
4
+ import { sweepInboundAttachments } from '../../src/channels/shared/inbound-file.mjs';
5
+
6
+ const DEFAULT_SWEEP_INTERVAL_MS = 30 * 60_000;
7
+
8
+ // A tracked directory is shielded from sweeps while its turn may still be
9
+ // running. Aborted or stalled turns never call cleanup(), so protection has
10
+ // an upper bound — far above the default reply timeout — after which the
11
+ // directory falls back under TTL control instead of leaking forever.
12
+ const TRACKED_PROTECTION_MS = 24 * 60 * 60_000;
13
+
14
+ /**
15
+ * Host-process coordinator for the global inbound attachment TTL. The staged
16
+ * registry marks directories owned by in-flight turns so a slow turn can
17
+ * outlive any TTL window without the sweeper deleting its files.
18
+ */
19
+ export function createInboundTtlService({
20
+ store,
21
+ logger = console,
22
+ intervalMs = DEFAULT_SWEEP_INTERVAL_MS,
23
+ } = {}) {
24
+ if (!store || typeof store.getTtlHours !== 'function') {
25
+ throw new TypeError('createInboundTtlService requires a store exposing getTtlHours()');
26
+ }
27
+ const tracked = new Map();
28
+ const providers = new Set();
29
+ let timer = null;
30
+
31
+ const runSweep = () => {
32
+ sweepNow().catch((error) => {
33
+ logger.error?.('[dsh-im] inbound attachment sweep failed', error);
34
+ });
35
+ };
36
+
37
+ function registerWorkspaceProvider(provider) {
38
+ if (typeof provider !== 'function') {
39
+ throw new TypeError('inbound TTL workspace provider must be a function');
40
+ }
41
+ const wasEmpty = providers.size === 0;
42
+ providers.add(provider);
43
+ // The startup sweep races channel activation and usually runs before any
44
+ // provider exists; re-sweep on the first registration so crash leftovers
45
+ // do not wait a full interval.
46
+ if (wasEmpty && timer) runSweep();
47
+ return () => {
48
+ providers.delete(provider);
49
+ };
50
+ }
51
+
52
+ function stagingRetention() {
53
+ // TTL 0 deletes at turn end; every other mode keeps files for the sweeper.
54
+ return store.getTtlHours() === 0 ? 'turn' : 'persistent';
55
+ }
56
+
57
+ function canonicalPath(directory) {
58
+ try {
59
+ return realpathSync(directory);
60
+ } catch {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function trackDirectory(directory, { now = () => Date.now() } = {}) {
66
+ if (typeof directory !== 'string' || !directory) return;
67
+ const trackedAt = now();
68
+ tracked.set(directory, trackedAt);
69
+ // The harness-reported cwd and workspaceFor() may normalize symlinks
70
+ // differently; track both spellings so either side finds the entry.
71
+ const canonical = canonicalPath(directory);
72
+ if (canonical && canonical !== directory) tracked.set(canonical, trackedAt);
73
+ }
74
+
75
+ function releaseDirectory(directory) {
76
+ tracked.delete(directory);
77
+ const canonical = canonicalPath(directory);
78
+ if (canonical && canonical !== directory) tracked.delete(canonical);
79
+ }
80
+
81
+ function isTrackedNow(directory) {
82
+ const trackedAt = tracked.get(directory);
83
+ if (trackedAt === undefined) return false;
84
+ if (Date.now() - trackedAt >= TRACKED_PROTECTION_MS) {
85
+ tracked.delete(directory);
86
+ return false;
87
+ }
88
+ return true;
89
+ }
90
+
91
+ function trackStaged(staged) {
92
+ if (!staged || typeof staged !== 'object') return staged;
93
+ const { directory } = staged;
94
+ if (typeof directory !== 'string' || !directory) return staged;
95
+ trackDirectory(directory);
96
+ const cleanup = typeof staged.cleanup === 'function' ? staged.cleanup : null;
97
+ return Object.freeze({
98
+ ...staged,
99
+ async cleanup() {
100
+ try {
101
+ await cleanup?.call(staged);
102
+ } finally {
103
+ releaseDirectory(directory);
104
+ }
105
+ },
106
+ });
107
+ }
108
+
109
+ async function sweepNow() {
110
+ const ttlHours = store.getTtlHours();
111
+ // Keep-forever never deletes, so no workspace is even inspected.
112
+ if (ttlHours === -1) return { deletedDirectories: 0, sweptWorkspaces: 0 };
113
+ const workspaces = new Set();
114
+ for (const provider of providers) {
115
+ try {
116
+ const provided = await provider();
117
+ for (const workspace of Array.isArray(provided) ? provided : []) {
118
+ if (typeof workspace === 'string' && workspace) workspaces.add(resolve(workspace));
119
+ }
120
+ } catch (error) {
121
+ logger.warn?.('[dsh-im] inbound TTL workspace provider failed', error);
122
+ }
123
+ }
124
+ const isTracked = (directory) => isTrackedNow(directory)
125
+ || (canonicalPath(directory) !== null && isTrackedNow(canonicalPath(directory)));
126
+ let deletedDirectories = 0;
127
+ let sweptWorkspaces = 0;
128
+ for (const workspace of workspaces) {
129
+ try {
130
+ const result = await sweepInboundAttachments(workspace, ttlHours, { isTracked });
131
+ deletedDirectories += result.deleted;
132
+ sweptWorkspaces += 1;
133
+ } catch (error) {
134
+ logger.warn?.(`[dsh-im] inbound attachment sweep failed under ${workspace}`, error);
135
+ }
136
+ }
137
+ if (deletedDirectories > 0) {
138
+ logger.info?.(
139
+ `[dsh-im] inbound attachment sweep deleted ${deletedDirectories} directories`
140
+ + ` across ${sweptWorkspaces} workspaces (ttl ${ttlHours}h)`,
141
+ );
142
+ }
143
+ return { deletedDirectories, sweptWorkspaces };
144
+ }
145
+
146
+ function start() {
147
+ if (timer) return;
148
+ runSweep();
149
+ timer = setInterval(runSweep, intervalMs);
150
+ timer.unref?.();
151
+ }
152
+
153
+ // A freshly saved TTL postpones the next scheduled sweep by a full
154
+ // interval so the new value is never enforced by a sweep firing moments
155
+ // after the change, and is not starved of one either.
156
+ function resetSweepSchedule() {
157
+ if (!timer) return;
158
+ clearInterval(timer);
159
+ timer = setInterval(runSweep, intervalMs);
160
+ timer.unref?.();
161
+ }
162
+
163
+ function stop() {
164
+ if (!timer) return;
165
+ clearInterval(timer);
166
+ timer = null;
167
+ }
168
+
169
+ return Object.freeze({
170
+ registerWorkspaceProvider,
171
+ stagingRetention,
172
+ trackDirectory,
173
+ trackStaged,
174
+ sweepNow,
175
+ start,
176
+ resetSweepSchedule,
177
+ stop,
178
+ });
179
+ }