shennian 0.3.85 → 0.3.86

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 (99) hide show
  1. package/dist/bin/shennian.js +1 -1
  2. package/dist/publish-build-manifest.json +28 -28
  3. package/dist/src/agents/adapter.js +19 -1
  4. package/dist/src/agents/claude-managed-permissions.js +248 -1
  5. package/dist/src/agents/claude.js +393 -8
  6. package/dist/src/agents/codex-managed-permissions.js +294 -1
  7. package/dist/src/agents/codex-managed-room-tools.js +249 -1
  8. package/dist/src/agents/codex-utils.js +200 -7
  9. package/dist/src/agents/codex.js +1352 -17
  10. package/dist/src/agents/config-status.js +226 -1
  11. package/dist/src/agents/cursor-managed-permissions.js +299 -2
  12. package/dist/src/agents/cursor.js +314 -1
  13. package/dist/src/agents/custom.js +418 -4
  14. package/dist/src/agents/external-channel-instructions.js +94 -11
  15. package/dist/src/agents/gemini.js +180 -1
  16. package/dist/src/agents/manager.d.ts +2 -0
  17. package/dist/src/agents/manager.js +250 -18
  18. package/dist/src/agents/model-registry/cache.js +37 -1
  19. package/dist/src/agents/model-registry/discovery.js +188 -2
  20. package/dist/src/agents/model-registry/parsers.js +521 -4
  21. package/dist/src/agents/model-registry/runner.js +30 -1
  22. package/dist/src/agents/model-registry/service.js +115 -1
  23. package/dist/src/agents/model-registry/types.js +8 -1
  24. package/dist/src/agents/model-registry.js +19 -1
  25. package/dist/src/agents/native-capabilities.js +237 -1
  26. package/dist/src/agents/openclaw.js +293 -2
  27. package/dist/src/agents/opencode-managed-permissions.js +187 -2
  28. package/dist/src/agents/opencode.js +298 -1
  29. package/dist/src/agents/platform-instructions.js +46 -17
  30. package/dist/src/agents/runtime-compatibility.js +44 -1
  31. package/dist/src/agents/workbuddy-managed-permissions.js +329 -2
  32. package/dist/src/agents/workbuddy.js +444 -3
  33. package/dist/src/commands/room.js +2 -2
  34. package/dist/src/fs/boundary.js +185 -1
  35. package/dist/src/fs/handler.js +130 -1
  36. package/dist/src/fs/security.js +32 -1
  37. package/dist/src/fs/text-decoder.js +110 -1
  38. package/dist/src/index.js +587 -3
  39. package/dist/src/manager/prompt.js +34 -29
  40. package/dist/src/manager/registry.js +317 -1
  41. package/dist/src/manager/retired-external-channel-compat.js +40 -1
  42. package/dist/src/manager/runtime.d.ts +4 -0
  43. package/dist/src/manager/runtime.js +1514 -23
  44. package/dist/src/native-fusion/codex-parser.js +660 -4
  45. package/dist/src/native-fusion/codex-user-input.js +102 -5
  46. package/dist/src/native-fusion/config.js +5 -1
  47. package/dist/src/native-fusion/file-watcher.js +191 -1
  48. package/dist/src/native-fusion/local-repository.js +569 -2
  49. package/dist/src/native-fusion/opencode-parser.js +130 -3
  50. package/dist/src/native-fusion/parsers.js +140 -3
  51. package/dist/src/native-fusion/service.js +739 -2
  52. package/dist/src/native-fusion/session-header.js +101 -2
  53. package/dist/src/native-fusion/state.js +104 -2
  54. package/dist/src/native-fusion/types.js +1 -1
  55. package/dist/src/personal-sync/machine-authority.js +34 -1
  56. package/dist/src/room/activation-audit.js +45 -2
  57. package/dist/src/room/activation-policy.js +30 -1
  58. package/dist/src/room/activation-service.d.ts +1 -0
  59. package/dist/src/room/activation-service.js +408 -8
  60. package/dist/src/room/invite-bootstrap.js +156 -1
  61. package/dist/src/room/managed-context.d.ts +1 -1
  62. package/dist/src/room/managed-context.js +1 -1
  63. package/dist/src/room/managed-prompt-composer.d.ts +1 -1
  64. package/dist/src/room/managed-prompt-composer.js +6 -5
  65. package/dist/src/room/managed-room-binding.js +167 -1
  66. package/dist/src/room/managed-room-ipc.d.ts +2 -2
  67. package/dist/src/room/managed-room-ipc.js +2 -2
  68. package/dist/src/room/mirror-sync-service.js +183 -1
  69. package/dist/src/room/room-action-client.d.ts +11 -0
  70. package/dist/src/room/room-action-client.js +1 -1
  71. package/dist/src/room/room-binding-client.d.ts +1 -0
  72. package/dist/src/room/room-binding-client.js +151 -1
  73. package/dist/src/security/managed-agent-policy.d.ts +3 -2
  74. package/dist/src/security/managed-agent-policy.js +1 -1
  75. package/dist/src/session/archive-zip.js +220 -1
  76. package/dist/src/session/deletion-sync-state.js +136 -2
  77. package/dist/src/session/handlers/agent-capabilities.js +14 -1
  78. package/dist/src/session/handlers/agent-config.js +150 -1
  79. package/dist/src/session/handlers/agents.js +55 -1
  80. package/dist/src/session/handlers/chat.d.ts +1 -1
  81. package/dist/src/session/handlers/chat.js +1030 -2
  82. package/dist/src/session/handlers/control.js +61 -1
  83. package/dist/src/session/handlers/fs.js +594 -1
  84. package/dist/src/session/handlers/message-status.js +15 -1
  85. package/dist/src/session/handlers/remote-access.js +43 -1
  86. package/dist/src/session/handlers/room-managed.js +101 -1
  87. package/dist/src/session/handlers/session-refresh.js +35 -1
  88. package/dist/src/session/handlers/title.js +79 -1
  89. package/dist/src/session/handlers/tool-detail.js +149 -1
  90. package/dist/src/session/handlers/voice-window.js +62 -1
  91. package/dist/src/session/history-subscriptions.js +470 -1
  92. package/dist/src/session/manager.js +658 -1
  93. package/dist/src/session/native-cleanup-outbox.js +287 -2
  94. package/dist/src/session/native-session-lifecycle.js +327 -6
  95. package/dist/src/session/queue.d.ts +5 -0
  96. package/dist/src/session/queue.js +8 -8
  97. package/dist/src/upgrade/scheduler.js +126 -1
  98. package/node_modules/@shennian/wire/dist/agent-workspace.js +1 -1
  99. package/package.json +1 -1
@@ -1,2 +1,287 @@
1
- import l from"node:crypto";import r from"node:fs";import p from"node:path";import{resolvePersonalSyncV1Path as m}from"../personal-sync/paths.js";import{getDaemonInstallationId as h}from"../personal-sync/identity.js";import{NativeSourceDeletionStore as y}from"../native-fusion/native-source-deletions.js";import{NativeSessionLifecycleRegistry as f}from"./native-session-lifecycle.js";const d=1,I=8,S=6e4,b=360*60*1e3,u=new Set(["succeeded_archived","succeeded_deleted","succeeded_already_absent","skipped_unsupported","skipped_invalid_identity","failed_permanent"]);class x{options;lifecycle;timer=null;drainPromise=null;constructor(t={}){this.options=t,this.lifecycle=t.lifecycle??new f}start(){this.timer||(this.drain(),this.timer=setInterval(()=>{this.drain()},this.options.drainIntervalMs??S),this.timer.unref?.())}stop(){this.timer&&clearInterval(this.timer),this.timer=null}enqueue(t){if((t.nativeCleanupPolicy??"none")!=="auto")return[];const s=this.read(!0),i=[];for(const e of t.sources){const o=this.jobId(t,e.agentType,e.sourceSessionKey),n=s.jobs[o];if(n){i.push(n);continue}const c={schemaVersion:d,daemonInstallationId:this.installationId(),jobId:o,sessionId:t.sessionId,deletionRevision:t.deletionRevision,agentType:e.agentType,sourceSessionKey:e.sourceSessionKey,...t.workDir?{workDir:t.workDir}:{},requestedAt:t.deletedAt,state:"pending",attempts:0};s.jobs[o]=c,i.push(c)}return i.some(e=>e.state==="pending")&&this.write(s),i}kick(){this.drain()}async drain(){return this.drainPromise?await this.drainPromise:(this.drainPromise=this.drainOnce().finally(()=>{this.drainPromise=null}),await this.drainPromise)}listJobs(){return Object.values(this.read(!0).jobs).sort((t,s)=>t.deletionRevision-s.deletionRevision||t.jobId.localeCompare(s.jobId))}async drainOnce(){for(;;){const t=this.read(!0),s=this.now(),i=Object.values(t.jobs).filter(o=>o.state==="pending"||o.state==="retry_wait"&&(!o.nextAttemptAt||Date.parse(o.nextAttemptAt)<=s.getTime())).sort((o,n)=>o.deletionRevision-n.deletionRevision||o.jobId.localeCompare(n.jobId))[0];if(!i)return;i.state="running",i.attempts+=1,delete i.nextAttemptAt,this.write(t);let e;if(!this.sourceGuard(i))e={outcome:"invalid_identity"};else try{e=await this.lifecycle.execute(i.agentType,i.sourceSessionKey,{workDir:i.workDir})}catch{e={outcome:"transient",errorCode:"lifecycle_unexpected_failure"}}this.applyResult(i.jobId,e)}}applyResult(t,s){const i=this.read(!1),e=i.jobs[t];if(!e||u.has(e.state))return;const o=this.now().toISOString();switch(s.outcome){case"archived":e.state="succeeded_archived";break;case"deleted":e.state="succeeded_deleted";break;case"already_absent":e.state="succeeded_already_absent";break;case"unsupported":e.state="skipped_unsupported";break;case"invalid_identity":e.state="skipped_invalid_identity";break;case"permanent":e.state="failed_permanent",e.lastErrorCode=s.errorCode;break;case"transient":{if(e.lastErrorCode=s.errorCode,e.attempts>=(this.options.maxAttempts??I)){e.state="failed_permanent";break}e.state="retry_wait";const n=Math.min((this.options.retryBaseMs??5e3)*2**Math.max(0,e.attempts-1),b);e.nextAttemptAt=new Date(this.now().getTime()+n).toISOString(),delete e.completedAt,this.write(i),this.log(e,s.errorCode);return}}e.completedAt=o,delete e.nextAttemptAt,this.write(i),this.log(e,e.lastErrorCode??s.outcome)}sourceGuard(t){if(this.options.sourceGuard)return this.options.sourceGuard(t);const s=new y({rootDir:this.rootDir(),daemonInstallationId:this.installationId()}).find(t.agentType,t.sourceSessionKey);return!!(s&&s.sessionId===t.sessionId&&s.deletionRevision===t.deletionRevision)}log(t,s){this.options.logger?.("[native-cleanup] job state changed",{job:t.jobId.slice(0,12),provider:t.agentType,state:t.state,result:s,attempts:t.attempts})}read(t){const s=this.filePath();if(!r.existsSync(s))return this.emptyFile();const i=JSON.parse(r.readFileSync(s,"utf8"));if(i.schemaVersion!==d||i.daemonInstallationId!==this.installationId()||!i.jobs||typeof i.jobs!="object"||Array.isArray(i.jobs))throw new Error("invalid native cleanup outbox");let e=!1;for(const[o,n]of Object.entries(i.jobs)){if(o!==n.jobId||n.schemaVersion!==d||n.daemonInstallationId!==this.installationId()||!n.sessionId||!n.agentType||!n.sourceSessionKey||!Number.isSafeInteger(n.deletionRevision)||n.deletionRevision<1||!Number.isSafeInteger(n.attempts)||n.attempts<0||!u.has(n.state)&&n.state!=="pending"&&n.state!=="running"&&n.state!=="retry_wait")throw new Error("invalid native cleanup job");t&&n.state==="running"&&(n.state="pending",e=!0)}return e&&this.write(i),i}write(t){v(this.filePath(),t)}emptyFile(){return{schemaVersion:d,daemonInstallationId:this.installationId(),jobs:{}}}jobId(t,s,i){return l.createHash("sha256").update(`${this.installationId()}\0${t.deletionRevision}\0${s}\0${i}`).digest("hex")}installationId(){return this.options.daemonInstallationId??h()}rootDir(){return this.options.rootDir??m()}filePath(){return p.join(this.rootDir(),"native-session-cleanup-outbox.json")}now(){return this.options.now?.()??new Date}}function v(a,t){const s=p.dirname(a);r.mkdirSync(s,{recursive:!0,mode:448});const i=`${a}.tmp-${process.pid}-${l.randomBytes(6).toString("hex")}`;let e;try{if(e=r.openSync(i,"wx",384),r.writeFileSync(e,`${JSON.stringify(t,null,2)}
2
- `,"utf8"),r.fsyncSync(e),r.closeSync(e),e=void 0,r.renameSync(i,a),process.platform!=="win32"){const o=r.openSync(s,"r");try{r.fsyncSync(o)}finally{r.closeSync(o)}}}finally{e!==void 0&&r.closeSync(e);try{r.unlinkSync(i)}catch{}}}export{x as NativeSessionCleanupOutbox};
1
+ // @arch docs/features/native-session-deletion-sync.md#七本机原生清理-outbox
2
+ // @test src/__tests__/native-cleanup-outbox.test.ts
3
+ import crypto from 'node:crypto';
4
+ import fs from 'node:fs';
5
+ import path from 'node:path';
6
+ import { resolvePersonalSyncV1Path } from '../personal-sync/paths.js';
7
+ import { getDaemonInstallationId } from '../personal-sync/identity.js';
8
+ import { NativeSourceDeletionStore } from '../native-fusion/native-source-deletions.js';
9
+ import { NativeSessionLifecycleRegistry, } from './native-session-lifecycle.js';
10
+ const SCHEMA_VERSION = 1;
11
+ const DEFAULT_MAX_ATTEMPTS = 8;
12
+ const DEFAULT_DRAIN_INTERVAL_MS = 60_000;
13
+ const MAX_BACKOFF_MS = 6 * 60 * 60 * 1_000;
14
+ const TERMINAL_STATES = new Set([
15
+ 'succeeded_archived',
16
+ 'succeeded_deleted',
17
+ 'succeeded_already_absent',
18
+ 'skipped_unsupported',
19
+ 'skipped_invalid_identity',
20
+ 'failed_permanent',
21
+ ]);
22
+ export class NativeSessionCleanupOutbox {
23
+ options;
24
+ lifecycle;
25
+ timer = null;
26
+ drainPromise = null;
27
+ constructor(options = {}) {
28
+ this.options = options;
29
+ this.lifecycle = options.lifecycle ?? new NativeSessionLifecycleRegistry();
30
+ }
31
+ start() {
32
+ if (this.timer)
33
+ return;
34
+ void this.drain();
35
+ this.timer = setInterval(() => void this.drain(), this.options.drainIntervalMs ?? DEFAULT_DRAIN_INTERVAL_MS);
36
+ this.timer.unref?.();
37
+ }
38
+ stop() {
39
+ if (this.timer)
40
+ clearInterval(this.timer);
41
+ this.timer = null;
42
+ }
43
+ enqueue(deletion) {
44
+ if ((deletion.nativeCleanupPolicy ?? 'none') !== 'auto')
45
+ return [];
46
+ const file = this.read(true);
47
+ const created = [];
48
+ for (const source of deletion.sources) {
49
+ const jobId = this.jobId(deletion, source.agentType, source.sourceSessionKey);
50
+ const existing = file.jobs[jobId];
51
+ if (existing) {
52
+ created.push(existing);
53
+ continue;
54
+ }
55
+ const job = {
56
+ schemaVersion: SCHEMA_VERSION,
57
+ daemonInstallationId: this.installationId(),
58
+ jobId,
59
+ sessionId: deletion.sessionId,
60
+ deletionRevision: deletion.deletionRevision,
61
+ agentType: source.agentType,
62
+ sourceSessionKey: source.sourceSessionKey,
63
+ ...(deletion.workDir ? { workDir: deletion.workDir } : {}),
64
+ requestedAt: deletion.deletedAt,
65
+ state: 'pending',
66
+ attempts: 0,
67
+ };
68
+ file.jobs[jobId] = job;
69
+ created.push(job);
70
+ }
71
+ if (created.some((job) => job.state === 'pending'))
72
+ this.write(file);
73
+ return created;
74
+ }
75
+ kick() {
76
+ void this.drain();
77
+ }
78
+ async drain() {
79
+ if (this.drainPromise)
80
+ return await this.drainPromise;
81
+ this.drainPromise = this.drainOnce().finally(() => {
82
+ this.drainPromise = null;
83
+ });
84
+ return await this.drainPromise;
85
+ }
86
+ listJobs() {
87
+ return Object.values(this.read(true).jobs).sort((left, right) => left.deletionRevision - right.deletionRevision || left.jobId.localeCompare(right.jobId));
88
+ }
89
+ async drainOnce() {
90
+ // One global worker is an intentional upper bound. It also guarantees that
91
+ // every Provider is serialized without a second lock hierarchy.
92
+ while (true) {
93
+ const file = this.read(true);
94
+ const now = this.now();
95
+ const job = Object.values(file.jobs)
96
+ .filter((candidate) => candidate.state === 'pending' ||
97
+ (candidate.state === 'retry_wait' &&
98
+ (!candidate.nextAttemptAt || Date.parse(candidate.nextAttemptAt) <= now.getTime())))
99
+ .sort((left, right) => left.deletionRevision - right.deletionRevision || left.jobId.localeCompare(right.jobId))[0];
100
+ if (!job)
101
+ return;
102
+ job.state = 'running';
103
+ job.attempts += 1;
104
+ delete job.nextAttemptAt;
105
+ this.write(file);
106
+ let result;
107
+ if (!this.sourceGuard(job)) {
108
+ result = { outcome: 'invalid_identity' };
109
+ }
110
+ else {
111
+ try {
112
+ result = await this.lifecycle.execute(job.agentType, job.sourceSessionKey, {
113
+ workDir: job.workDir,
114
+ });
115
+ }
116
+ catch {
117
+ result = { outcome: 'transient', errorCode: 'lifecycle_unexpected_failure' };
118
+ }
119
+ }
120
+ this.applyResult(job.jobId, result);
121
+ }
122
+ }
123
+ applyResult(jobId, result) {
124
+ const file = this.read(false);
125
+ const job = file.jobs[jobId];
126
+ if (!job || TERMINAL_STATES.has(job.state))
127
+ return;
128
+ const completedAt = this.now().toISOString();
129
+ switch (result.outcome) {
130
+ case 'archived':
131
+ job.state = 'succeeded_archived';
132
+ break;
133
+ case 'deleted':
134
+ job.state = 'succeeded_deleted';
135
+ break;
136
+ case 'already_absent':
137
+ job.state = 'succeeded_already_absent';
138
+ break;
139
+ case 'unsupported':
140
+ job.state = 'skipped_unsupported';
141
+ break;
142
+ case 'invalid_identity':
143
+ job.state = 'skipped_invalid_identity';
144
+ break;
145
+ case 'permanent':
146
+ job.state = 'failed_permanent';
147
+ job.lastErrorCode = result.errorCode;
148
+ break;
149
+ case 'transient': {
150
+ job.lastErrorCode = result.errorCode;
151
+ if (job.attempts >= (this.options.maxAttempts ?? DEFAULT_MAX_ATTEMPTS)) {
152
+ job.state = 'failed_permanent';
153
+ break;
154
+ }
155
+ job.state = 'retry_wait';
156
+ const delay = Math.min((this.options.retryBaseMs ?? 5_000) * 2 ** Math.max(0, job.attempts - 1), MAX_BACKOFF_MS);
157
+ job.nextAttemptAt = new Date(this.now().getTime() + delay).toISOString();
158
+ delete job.completedAt;
159
+ this.write(file);
160
+ this.log(job, result.errorCode);
161
+ return;
162
+ }
163
+ }
164
+ job.completedAt = completedAt;
165
+ delete job.nextAttemptAt;
166
+ this.write(file);
167
+ this.log(job, job.lastErrorCode ?? result.outcome);
168
+ }
169
+ sourceGuard(job) {
170
+ if (this.options.sourceGuard)
171
+ return this.options.sourceGuard(job);
172
+ const deletion = new NativeSourceDeletionStore({
173
+ rootDir: this.rootDir(),
174
+ daemonInstallationId: this.installationId(),
175
+ }).find(job.agentType, job.sourceSessionKey);
176
+ return Boolean(deletion &&
177
+ deletion.sessionId === job.sessionId &&
178
+ deletion.deletionRevision === job.deletionRevision);
179
+ }
180
+ log(job, result) {
181
+ this.options.logger?.('[native-cleanup] job state changed', {
182
+ job: job.jobId.slice(0, 12),
183
+ provider: job.agentType,
184
+ state: job.state,
185
+ result,
186
+ attempts: job.attempts,
187
+ });
188
+ }
189
+ read(recoverRunning) {
190
+ const filePath = this.filePath();
191
+ if (!fs.existsSync(filePath))
192
+ return this.emptyFile();
193
+ const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8'));
194
+ if (parsed.schemaVersion !== SCHEMA_VERSION ||
195
+ parsed.daemonInstallationId !== this.installationId() ||
196
+ !parsed.jobs ||
197
+ typeof parsed.jobs !== 'object' ||
198
+ Array.isArray(parsed.jobs)) {
199
+ throw new Error('invalid native cleanup outbox');
200
+ }
201
+ let recovered = false;
202
+ for (const [key, job] of Object.entries(parsed.jobs)) {
203
+ if (key !== job.jobId ||
204
+ job.schemaVersion !== SCHEMA_VERSION ||
205
+ job.daemonInstallationId !== this.installationId() ||
206
+ !job.sessionId ||
207
+ !job.agentType ||
208
+ !job.sourceSessionKey ||
209
+ !Number.isSafeInteger(job.deletionRevision) ||
210
+ job.deletionRevision < 1 ||
211
+ !Number.isSafeInteger(job.attempts) ||
212
+ job.attempts < 0 ||
213
+ (!TERMINAL_STATES.has(job.state) &&
214
+ job.state !== 'pending' &&
215
+ job.state !== 'running' &&
216
+ job.state !== 'retry_wait')) {
217
+ throw new Error('invalid native cleanup job');
218
+ }
219
+ if (recoverRunning && job.state === 'running') {
220
+ job.state = 'pending';
221
+ recovered = true;
222
+ }
223
+ }
224
+ if (recovered)
225
+ this.write(parsed);
226
+ return parsed;
227
+ }
228
+ write(file) {
229
+ atomicWriteJson(this.filePath(), file);
230
+ }
231
+ emptyFile() {
232
+ return {
233
+ schemaVersion: SCHEMA_VERSION,
234
+ daemonInstallationId: this.installationId(),
235
+ jobs: {},
236
+ };
237
+ }
238
+ jobId(deletion, agentType, sourceSessionKey) {
239
+ return crypto
240
+ .createHash('sha256')
241
+ .update(`${this.installationId()}\0${deletion.deletionRevision}\0${agentType}\0${sourceSessionKey}`)
242
+ .digest('hex');
243
+ }
244
+ installationId() {
245
+ return this.options.daemonInstallationId ?? getDaemonInstallationId();
246
+ }
247
+ rootDir() {
248
+ return this.options.rootDir ?? resolvePersonalSyncV1Path();
249
+ }
250
+ filePath() {
251
+ return path.join(this.rootDir(), 'native-session-cleanup-outbox.json');
252
+ }
253
+ now() {
254
+ return this.options.now?.() ?? new Date();
255
+ }
256
+ }
257
+ function atomicWriteJson(file, value) {
258
+ const directory = path.dirname(file);
259
+ fs.mkdirSync(directory, { recursive: true, mode: 0o700 });
260
+ const temp = `${file}.tmp-${process.pid}-${crypto.randomBytes(6).toString('hex')}`;
261
+ let descriptor;
262
+ try {
263
+ descriptor = fs.openSync(temp, 'wx', 0o600);
264
+ fs.writeFileSync(descriptor, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
265
+ fs.fsyncSync(descriptor);
266
+ fs.closeSync(descriptor);
267
+ descriptor = undefined;
268
+ fs.renameSync(temp, file);
269
+ if (process.platform !== 'win32') {
270
+ const directoryDescriptor = fs.openSync(directory, 'r');
271
+ try {
272
+ fs.fsyncSync(directoryDescriptor);
273
+ }
274
+ finally {
275
+ fs.closeSync(directoryDescriptor);
276
+ }
277
+ }
278
+ }
279
+ finally {
280
+ if (descriptor !== undefined)
281
+ fs.closeSync(descriptor);
282
+ try {
283
+ fs.unlinkSync(temp);
284
+ }
285
+ catch { }
286
+ }
287
+ }
@@ -1,6 +1,327 @@
1
- import{createInterface as E}from"node:readline";import D from"node:fs";import{isAvailableBuiltinAgentType as g}from"@shennian/wire";import{buildAgentProcessEnv as w}from"../agent-env.js";import{resolveBuiltinCommand as b,spawnAgentCommand as y}from"../agents/command-spec.js";const C=64*1024,h=/^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,k=/^ses_[A-Za-z0-9_-]{6,191}$/;function A(n,r){return n.length>=C?n:`${n}${String(r)}`.slice(0,C)}const x=async(n,r,i=1e4,e)=>{if(!g(n))return{code:null,stdout:"",stderr:"",timedOut:!1,spawnErrorCode:"command_unavailable"};const t=b(n);return t?await new Promise(c=>{let s="",l="",d=!1,m=!1;const p=y(t,r,{stdio:["ignore","pipe","pipe"],env:w({NO_COLOR:"1"}),...e?{cwd:e}:{}}),u=o=>{d||(d=!0,a&&clearTimeout(a),c({...o,stdout:s,stderr:l,timedOut:m}))};p.stdout?.on("data",o=>{s=A(s,o)}),p.stderr?.on("data",o=>{l=A(l,o)}),p.on("error",o=>{u({code:null,spawnErrorCode:o.code??"spawn_failed"})}),p.on("close",o=>u({code:o}));const a=setTimeout(()=>{m=!0,p.kill("SIGTERM"),setTimeout(()=>p.kill("SIGKILL"),1e3).unref?.()},i);a.unref?.()}):{code:null,stdout:"",stderr:"",timedOut:!1,spawnErrorCode:"command_unavailable"}};function $(n){return`${n.stdout}
2
- ${n.stderr}`.toLowerCase()}function f(n,r){if(n.spawnErrorCode==="command_unavailable"||n.spawnErrorCode==="ENOENT")return{outcome:"unsupported"};if(n.timedOut)return{outcome:"transient",errorCode:"command_timeout"};const i=$(n);return/not found|does not exist|no session|unknown session|already (?:deleted|archived)/.test(i)?{outcome:"already_absent"}:/busy|locked|writer|in use|current active session|active turn|temporar|try again|timed out|timeout/.test(i)?{outcome:"transient",errorCode:"native_busy"}:/invalid.*(?:uuid|session|id)|malformed/.test(i)?{outcome:"invalid_identity"}:/permission denied|unauthenticated|authentication|forbidden/.test(i)?{outcome:"permanent",errorCode:"native_auth_required"}:n.code===0?{outcome:r}:{outcome:"transient",errorCode:"native_command_failed"}}async function _(n,r,i,e){const t=await n(r,i);return!t.timedOut&&t.code===0&&e.test(`${t.stdout}
3
- ${t.stderr}`)}async function I(n,r){return await new Promise(i=>{let e=null,t=!1,c=1,s=null;const l=new Map,d=u=>{if(!t){t=!0,s&&clearTimeout(s);for(const a of l.values())a.reject(new Error("app_server_stopped"));l.clear(),e?.kill("SIGTERM"),i(u)}},m=(u,a)=>{if(!e?.stdin)return Promise.reject(new Error("app_server_unavailable"));const o=c++;return new Promise((v,O)=>{l.set(o,{resolve:v,reject:O}),e.stdin.write(`${JSON.stringify({id:o,method:u,params:a})}
4
- `)})};try{e=y(n,["app-server","--listen","stdio://"],{stdio:["pipe","pipe","ignore"],env:w({NO_COLOR:"1"})})}catch{d({outcome:"transient",errorCode:"app_server_start_failed"});return}E({input:e.stdout}).on("line",u=>{if(!u.trim())return;let a;try{a=JSON.parse(u)}catch{return}if(a.id==null)return;const o=l.get(a.id);if(o)if(l.delete(a.id),a.error){const v=new Error(a.error.message??"app_server_error");v.rpcCode=a.error.code,o.reject(v)}else o.resolve(a.result)}),e.on("error",()=>d({outcome:"transient",errorCode:"app_server_start_failed"})),e.on("close",()=>{t||d({outcome:"transient",errorCode:"app_server_stopped"})}),s=setTimeout(()=>d({outcome:"transient",errorCode:"app_server_timeout"}),12e3),s.unref?.(),(async()=>{try{await m("initialize",{clientInfo:{name:"shennian-native-cleanup",title:"Shennian",version:"1"},capabilities:{experimentalApi:!0}}),await m("thread/archive",{threadId:r}),d({outcome:"archived"})}catch(u){const a=u,o=a.message.toLowerCase();/not found|does not exist|already archived|no rollout/.test(o)?d({outcome:"already_absent"}):/busy|locked|writer|active turn|in use/.test(o)?d({outcome:"transient",errorCode:"native_busy"}):a.rpcCode===-32601||/method not found|unsupported/.test(o)?d({outcome:"unsupported"}):d({outcome:"transient",errorCode:"app_server_failed"})}})()})}class S{options;capabilities=new Map;constructor(r={}){this.options=r}async execute(r,i,e={}){const t=this.adapter(r),c=await this.probe(r,t);if(c.canArchive&&t.archive)return await t.archive(i);if(c.canDelete&&t.delete){if(r==="gemini"&&!e.workDir)return{outcome:"invalid_identity"};if((r==="gemini"||r==="opencode")&&e.workDir)try{if(!D.statSync(e.workDir).isDirectory())return{outcome:"permanent",errorCode:"workdir_unavailable"}}catch{return{outcome:"permanent",errorCode:"workdir_unavailable"}}return await this.deleteWithContext(t,i,e)}return{outcome:"unsupported"}}async deleteWithContext(r,i,e){return r.delete?await r.delete(i,e):{outcome:"unsupported"}}clearCapabilityCache(){this.capabilities.clear()}probe(r,i){const e=this.capabilities.get(r);if(e)return e;const t=i.probe().catch(()=>({canArchive:!1,canDelete:!1}));return this.capabilities.set(r,t),t}adapter(r){const i=this.options.runner??x;return r==="codex"?{probe:async()=>({canArchive:await _(i,"codex",["--help"],/\barchive\b/),canDelete:!1}),archive:async e=>{if(!h.test(e))return{outcome:"invalid_identity"};const t=this.options.codexArchive,c=b("codex");let s={outcome:"unsupported"};return t?s=await t(e):c&&(s=await I(c,e)),s.outcome==="archived"||s.outcome==="already_absent"||s.outcome==="invalid_identity"||s.outcome==="transient"&&s.errorCode==="native_busy"?s:f(await i("codex",["archive",e],15e3),"archived")}}:r==="gemini"?{probe:async()=>({canArchive:!1,canDelete:await _(i,"gemini",["--help"],/--delete-session\b/)}),delete:async(e,t)=>{if(!h.test(e))return{outcome:"invalid_identity"};const c=await i("gemini",["--delete-session",e],15e3,t?.workDir);return/invalid session identifier/i.test(`${c.stdout}
5
- ${c.stderr}`)?{outcome:"already_absent"}:f(c,"deleted")}}:r==="opencode"?{probe:async()=>({canArchive:!1,canDelete:await _(i,"opencode",["session","delete","--help"],/session delete\s+<sessionID>/i)}),delete:async(e,t)=>k.test(e)?f(await i("opencode",["session","delete",e],15e3,t?.workDir),"deleted"):{outcome:"invalid_identity"}}:r==="cursor"?{probe:async()=>{const e=await i("cursor",["--help"]),t=`${e.stdout}
6
- ${e.stderr}`;return{canArchive:e.code===0&&/\barchive-chat\b/.test(t),canDelete:e.code===0&&/\bdelete-chat\b/.test(t)}},archive:async e=>h.test(e)?f(await i("cursor",["archive-chat",e],15e3),"archived"):{outcome:"invalid_identity"},delete:async e=>h.test(e)?f(await i("cursor",["delete-chat",e],15e3),"deleted"):{outcome:"invalid_identity"}}:{probe:async()=>({canArchive:!1,canDelete:!1})}}}export{S as NativeSessionLifecycleRegistry,x as runNativeLifecycleCommand};
1
+ // @arch docs/features/native-session-deletion-sync.md#四provider-能力基线
2
+ // @test src/__tests__/native-session-lifecycle.test.ts
3
+ import { createInterface } from 'node:readline';
4
+ import fs from 'node:fs';
5
+ import { isAvailableBuiltinAgentType } from '@shennian/wire';
6
+ import { buildAgentProcessEnv } from '../agent-env.js';
7
+ import { resolveBuiltinCommand, spawnAgentCommand, } from '../agents/command-spec.js';
8
+ const OUTPUT_LIMIT = 64 * 1024;
9
+ const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[1-8][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i;
10
+ const OPENCODE_SESSION_PATTERN = /^ses_[A-Za-z0-9_-]{6,191}$/;
11
+ function appendLimited(current, chunk) {
12
+ if (current.length >= OUTPUT_LIMIT)
13
+ return current;
14
+ return `${current}${String(chunk)}`.slice(0, OUTPUT_LIMIT);
15
+ }
16
+ export const runNativeLifecycleCommand = async (agentType, args, timeoutMs = 10_000, cwd) => {
17
+ if (!isAvailableBuiltinAgentType(agentType)) {
18
+ return {
19
+ code: null,
20
+ stdout: '',
21
+ stderr: '',
22
+ timedOut: false,
23
+ spawnErrorCode: 'command_unavailable',
24
+ };
25
+ }
26
+ const spec = resolveBuiltinCommand(agentType);
27
+ if (!spec) {
28
+ return {
29
+ code: null,
30
+ stdout: '',
31
+ stderr: '',
32
+ timedOut: false,
33
+ spawnErrorCode: 'command_unavailable',
34
+ };
35
+ }
36
+ return await new Promise((resolve) => {
37
+ let stdout = '';
38
+ let stderr = '';
39
+ let settled = false;
40
+ let timedOut = false;
41
+ const proc = spawnAgentCommand(spec, args, {
42
+ stdio: ['ignore', 'pipe', 'pipe'],
43
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
44
+ ...(cwd ? { cwd } : {}),
45
+ });
46
+ const finish = (result) => {
47
+ if (settled)
48
+ return;
49
+ settled = true;
50
+ if (timer)
51
+ clearTimeout(timer);
52
+ resolve({ ...result, stdout, stderr, timedOut });
53
+ };
54
+ proc.stdout?.on('data', (chunk) => {
55
+ stdout = appendLimited(stdout, chunk);
56
+ });
57
+ proc.stderr?.on('data', (chunk) => {
58
+ stderr = appendLimited(stderr, chunk);
59
+ });
60
+ proc.on('error', (error) => {
61
+ finish({ code: null, spawnErrorCode: error.code ?? 'spawn_failed' });
62
+ });
63
+ proc.on('close', (code) => finish({ code }));
64
+ const timer = setTimeout(() => {
65
+ timedOut = true;
66
+ proc.kill('SIGTERM');
67
+ setTimeout(() => proc.kill('SIGKILL'), 1_000).unref?.();
68
+ }, timeoutMs);
69
+ timer.unref?.();
70
+ });
71
+ };
72
+ function safeOutput(result) {
73
+ return `${result.stdout}\n${result.stderr}`.toLowerCase();
74
+ }
75
+ function classifyCommandResult(result, success) {
76
+ if (result.spawnErrorCode === 'command_unavailable' || result.spawnErrorCode === 'ENOENT') {
77
+ return { outcome: 'unsupported' };
78
+ }
79
+ if (result.timedOut)
80
+ return { outcome: 'transient', errorCode: 'command_timeout' };
81
+ const output = safeOutput(result);
82
+ if (/not found|does not exist|no session|unknown session|already (?:deleted|archived)/.test(output)) {
83
+ return { outcome: 'already_absent' };
84
+ }
85
+ if (/busy|locked|writer|in use|current active session|active turn|temporar|try again|timed out|timeout/.test(output)) {
86
+ return { outcome: 'transient', errorCode: 'native_busy' };
87
+ }
88
+ if (/invalid.*(?:uuid|session|id)|malformed/.test(output)) {
89
+ return { outcome: 'invalid_identity' };
90
+ }
91
+ if (/permission denied|unauthenticated|authentication|forbidden/.test(output)) {
92
+ return { outcome: 'permanent', errorCode: 'native_auth_required' };
93
+ }
94
+ if (result.code === 0)
95
+ return { outcome: success };
96
+ return { outcome: 'transient', errorCode: 'native_command_failed' };
97
+ }
98
+ async function commandAdvertises(runner, agentType, args, pattern) {
99
+ const result = await runner(agentType, args);
100
+ return !result.timedOut && result.code === 0 && pattern.test(`${result.stdout}\n${result.stderr}`);
101
+ }
102
+ async function codexAppServerArchiveWithSpec(spec, threadId) {
103
+ return await new Promise((resolve) => {
104
+ let proc = null;
105
+ let settled = false;
106
+ let nextId = 1;
107
+ let overallTimer = null;
108
+ const pending = new Map();
109
+ const finish = (result) => {
110
+ if (settled)
111
+ return;
112
+ settled = true;
113
+ if (overallTimer)
114
+ clearTimeout(overallTimer);
115
+ for (const request of pending.values())
116
+ request.reject(new Error('app_server_stopped'));
117
+ pending.clear();
118
+ proc?.kill('SIGTERM');
119
+ resolve(result);
120
+ };
121
+ const request = (method, params) => {
122
+ if (!proc?.stdin)
123
+ return Promise.reject(new Error('app_server_unavailable'));
124
+ const id = nextId++;
125
+ return new Promise((requestResolve, requestReject) => {
126
+ pending.set(id, { resolve: requestResolve, reject: requestReject });
127
+ proc.stdin.write(`${JSON.stringify({ id, method, params })}\n`);
128
+ });
129
+ };
130
+ try {
131
+ proc = spawnAgentCommand(spec, ['app-server', '--listen', 'stdio://'], {
132
+ stdio: ['pipe', 'pipe', 'ignore'],
133
+ env: buildAgentProcessEnv({ NO_COLOR: '1' }),
134
+ });
135
+ }
136
+ catch {
137
+ finish({ outcome: 'transient', errorCode: 'app_server_start_failed' });
138
+ return;
139
+ }
140
+ const lines = createInterface({ input: proc.stdout });
141
+ lines.on('line', (line) => {
142
+ if (!line.trim())
143
+ return;
144
+ let message;
145
+ try {
146
+ message = JSON.parse(line);
147
+ }
148
+ catch {
149
+ return;
150
+ }
151
+ if (message.id == null)
152
+ return;
153
+ const waiter = pending.get(message.id);
154
+ if (!waiter)
155
+ return;
156
+ pending.delete(message.id);
157
+ if (message.error) {
158
+ const error = new Error(message.error.message ?? 'app_server_error');
159
+ error.rpcCode = message.error.code;
160
+ waiter.reject(error);
161
+ }
162
+ else {
163
+ waiter.resolve(message.result);
164
+ }
165
+ });
166
+ proc.on('error', () => finish({ outcome: 'transient', errorCode: 'app_server_start_failed' }));
167
+ proc.on('close', () => {
168
+ if (!settled)
169
+ finish({ outcome: 'transient', errorCode: 'app_server_stopped' });
170
+ });
171
+ overallTimer = setTimeout(() => finish({ outcome: 'transient', errorCode: 'app_server_timeout' }), 12_000);
172
+ overallTimer.unref?.();
173
+ void (async () => {
174
+ try {
175
+ await request('initialize', {
176
+ clientInfo: { name: 'shennian-native-cleanup', title: 'Shennian', version: '1' },
177
+ capabilities: { experimentalApi: true },
178
+ });
179
+ await request('thread/archive', { threadId });
180
+ finish({ outcome: 'archived' });
181
+ }
182
+ catch (error) {
183
+ const rpc = error;
184
+ const message = rpc.message.toLowerCase();
185
+ if (/not found|does not exist|already archived|no rollout/.test(message)) {
186
+ finish({ outcome: 'already_absent' });
187
+ }
188
+ else if (/busy|locked|writer|active turn|in use/.test(message)) {
189
+ finish({ outcome: 'transient', errorCode: 'native_busy' });
190
+ }
191
+ else if (rpc.rpcCode === -32601 || /method not found|unsupported/.test(message)) {
192
+ finish({ outcome: 'unsupported' });
193
+ }
194
+ else {
195
+ finish({ outcome: 'transient', errorCode: 'app_server_failed' });
196
+ }
197
+ }
198
+ })();
199
+ });
200
+ }
201
+ export class NativeSessionLifecycleRegistry {
202
+ options;
203
+ capabilities = new Map();
204
+ constructor(options = {}) {
205
+ this.options = options;
206
+ }
207
+ async execute(agentType, sourceSessionKey, context = {}) {
208
+ const adapter = this.adapter(agentType);
209
+ const capability = await this.probe(agentType, adapter);
210
+ if (capability.canArchive && adapter.archive)
211
+ return await adapter.archive(sourceSessionKey);
212
+ if (capability.canDelete && adapter.delete) {
213
+ if (agentType === 'gemini' && !context.workDir)
214
+ return { outcome: 'invalid_identity' };
215
+ if ((agentType === 'gemini' || agentType === 'opencode') && context.workDir) {
216
+ try {
217
+ if (!fs.statSync(context.workDir).isDirectory()) {
218
+ return { outcome: 'permanent', errorCode: 'workdir_unavailable' };
219
+ }
220
+ }
221
+ catch {
222
+ return { outcome: 'permanent', errorCode: 'workdir_unavailable' };
223
+ }
224
+ }
225
+ return await this.deleteWithContext(adapter, sourceSessionKey, context);
226
+ }
227
+ return { outcome: 'unsupported' };
228
+ }
229
+ async deleteWithContext(adapter, sourceSessionKey, context) {
230
+ if (!adapter.delete)
231
+ return { outcome: 'unsupported' };
232
+ return await adapter.delete(sourceSessionKey, context);
233
+ }
234
+ clearCapabilityCache() {
235
+ this.capabilities.clear();
236
+ }
237
+ probe(agentType, adapter) {
238
+ const existing = this.capabilities.get(agentType);
239
+ if (existing)
240
+ return existing;
241
+ const pending = adapter.probe().catch(() => ({ canArchive: false, canDelete: false }));
242
+ this.capabilities.set(agentType, pending);
243
+ return pending;
244
+ }
245
+ adapter(agentType) {
246
+ const runner = this.options.runner ?? runNativeLifecycleCommand;
247
+ if (agentType === 'codex') {
248
+ return {
249
+ probe: async () => ({
250
+ canArchive: await commandAdvertises(runner, 'codex', ['--help'], /\barchive\b/),
251
+ canDelete: false,
252
+ }),
253
+ archive: async (sourceSessionKey) => {
254
+ if (!UUID_PATTERN.test(sourceSessionKey))
255
+ return { outcome: 'invalid_identity' };
256
+ const custom = this.options.codexArchive;
257
+ const spec = resolveBuiltinCommand('codex');
258
+ let appServerResult = { outcome: 'unsupported' };
259
+ if (custom)
260
+ appServerResult = await custom(sourceSessionKey);
261
+ else if (spec)
262
+ appServerResult = await codexAppServerArchiveWithSpec(spec, sourceSessionKey);
263
+ if (appServerResult.outcome === 'archived' ||
264
+ appServerResult.outcome === 'already_absent' ||
265
+ appServerResult.outcome === 'invalid_identity' ||
266
+ (appServerResult.outcome === 'transient' && appServerResult.errorCode === 'native_busy'))
267
+ return appServerResult;
268
+ return classifyCommandResult(await runner('codex', ['archive', sourceSessionKey], 15_000), 'archived');
269
+ },
270
+ };
271
+ }
272
+ if (agentType === 'gemini') {
273
+ return {
274
+ probe: async () => ({
275
+ canArchive: false,
276
+ canDelete: await commandAdvertises(runner, 'gemini', ['--help'], /--delete-session\b/),
277
+ }),
278
+ delete: async (sourceSessionKey, context) => {
279
+ if (!UUID_PATTERN.test(sourceSessionKey))
280
+ return { outcome: 'invalid_identity' };
281
+ const result = await runner('gemini', ['--delete-session', sourceSessionKey], 15_000, context?.workDir);
282
+ if (/invalid session identifier/i.test(`${result.stdout}\n${result.stderr}`)) {
283
+ return { outcome: 'already_absent' };
284
+ }
285
+ return classifyCommandResult(result, 'deleted');
286
+ },
287
+ };
288
+ }
289
+ if (agentType === 'opencode') {
290
+ return {
291
+ probe: async () => ({
292
+ canArchive: false,
293
+ canDelete: await commandAdvertises(runner, 'opencode', ['session', 'delete', '--help'], /session delete\s+<sessionID>/i),
294
+ }),
295
+ delete: async (sourceSessionKey, context) => {
296
+ if (!OPENCODE_SESSION_PATTERN.test(sourceSessionKey)) {
297
+ return { outcome: 'invalid_identity' };
298
+ }
299
+ return classifyCommandResult(await runner('opencode', ['session', 'delete', sourceSessionKey], 15_000, context?.workDir), 'deleted');
300
+ },
301
+ };
302
+ }
303
+ if (agentType === 'cursor') {
304
+ return {
305
+ probe: async () => {
306
+ const result = await runner('cursor', ['--help']);
307
+ const output = `${result.stdout}\n${result.stderr}`;
308
+ return {
309
+ canArchive: result.code === 0 && /\barchive-chat\b/.test(output),
310
+ canDelete: result.code === 0 && /\bdelete-chat\b/.test(output),
311
+ };
312
+ },
313
+ archive: async (sourceSessionKey) => {
314
+ if (!UUID_PATTERN.test(sourceSessionKey))
315
+ return { outcome: 'invalid_identity' };
316
+ return classifyCommandResult(await runner('cursor', ['archive-chat', sourceSessionKey], 15_000), 'archived');
317
+ },
318
+ delete: async (sourceSessionKey) => {
319
+ if (!UUID_PATTERN.test(sourceSessionKey))
320
+ return { outcome: 'invalid_identity' };
321
+ return classifyCommandResult(await runner('cursor', ['delete-chat', sourceSessionKey], 15_000), 'deleted');
322
+ },
323
+ };
324
+ }
325
+ return { probe: async () => ({ canArchive: false, canDelete: false }) };
326
+ }
327
+ }
@@ -18,6 +18,9 @@ export type PreparedManagedRoomTurn = {
18
18
  managedRoomPromptSnapshot: ManagedRoomPromptSnapshot;
19
19
  };
20
20
  export type ManagedRoomEnqueueParams = Omit<ChatEnqueueParams, 'protocolVersion' | 'dataEpoch' | 'daemonInstallationId'>;
21
+ export declare class TerminalQueuedChatDispatchError extends Error {
22
+ constructor(message: string);
23
+ }
21
24
  export declare class ChatQueueManager {
22
25
  private opts;
23
26
  private draining;
@@ -41,6 +44,8 @@ export declare class ChatQueueManager {
41
44
  purgeSession(sessionId: string): void;
42
45
  private drainIdleQueues;
43
46
  private drainNext;
47
+ private removeBatch;
48
+ private logLifecycle;
44
49
  private dispatchQueuedMessages;
45
50
  private isSessionBusy;
46
51
  private broadcast;