@teammy/cli 0.0.10 → 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.
- package/README.md +31 -5
- package/bin/session-hook.js +14 -0
- package/bin/teammy.js +8 -1
- package/dist/hook.js +340 -0
- package/dist/main.js +2158 -948
- package/package.json +1 -1
package/dist/main.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// apps/client/main.ts
|
|
2
|
-
import { parseArgs as
|
|
3
|
-
import { resolve as
|
|
2
|
+
import { parseArgs as parseArgs6 } from "node:util";
|
|
3
|
+
import { resolve as resolve15 } from "node:path";
|
|
4
4
|
|
|
5
5
|
// packages/protocol/project.ts
|
|
6
6
|
import { z as z3 } from "zod";
|
|
@@ -19,7 +19,9 @@ var MembersSchema = z.object({ members: z.array(NameSchema) });
|
|
|
19
19
|
|
|
20
20
|
// packages/protocol/files.ts
|
|
21
21
|
import { z as z2 } from "zod";
|
|
22
|
-
var MAX_BLOB_BYTES =
|
|
22
|
+
var MAX_BLOB_BYTES = 128 * 1024 * 1024;
|
|
23
|
+
var MAX_FILE_BYTES = 10 * 1024 * 1024;
|
|
24
|
+
var BLOB_TRANSFER_TIMEOUT_MS = 3e5;
|
|
23
25
|
var MAX_BATCH_FILES = 200;
|
|
24
26
|
var HashSchema = z2.string().regex(/^[a-f0-9]{64}$/);
|
|
25
27
|
var RevisionSchema = z2.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
|
|
@@ -33,25 +35,29 @@ var BlobRefSchema = z2.object({
|
|
|
33
35
|
sha256: HashSchema,
|
|
34
36
|
size: z2.number().int().min(0).max(MAX_BLOB_BYTES)
|
|
35
37
|
});
|
|
38
|
+
var FileBlobRefSchema = BlobRefSchema.extend({
|
|
39
|
+
size: z2.number().int().min(0).max(MAX_FILE_BYTES)
|
|
40
|
+
});
|
|
36
41
|
var FileChangeSchema = z2.discriminatedUnion("kind", [
|
|
37
|
-
|
|
42
|
+
FileBlobRefSchema.extend({ kind: z2.literal("upsert"), path: FilePathSchema }).strict(),
|
|
38
43
|
z2.object({ kind: z2.literal("delete"), path: FilePathSchema }).strict()
|
|
39
44
|
]);
|
|
40
45
|
var FileCommitSchema = z2.object({
|
|
41
46
|
operationId: z2.uuid(),
|
|
42
47
|
parentRevision: RevisionSchema,
|
|
43
|
-
changes: z2.array(FileChangeSchema)
|
|
48
|
+
changes: z2.array(FileChangeSchema)
|
|
44
49
|
}).strict().refine(
|
|
45
50
|
(batch) => new Set(batch.changes.map((change) => change.path)).size === batch.changes.length,
|
|
46
|
-
"Duplicate paths in a
|
|
51
|
+
"Duplicate paths in a publication."
|
|
47
52
|
);
|
|
48
53
|
var CommitResultSchema = z2.object({ operationId: z2.uuid(), revision: RevisionSchema });
|
|
49
|
-
var FileEntrySchema =
|
|
54
|
+
var FileEntrySchema = FileBlobRefSchema.extend({
|
|
50
55
|
path: FilePathSchema,
|
|
51
56
|
revision: RevisionSchema
|
|
52
57
|
});
|
|
53
58
|
var ManifestSchema = z2.object({
|
|
54
59
|
revision: RevisionSchema,
|
|
60
|
+
complete: z2.boolean().default(false),
|
|
55
61
|
files: z2.array(FileEntrySchema),
|
|
56
62
|
nextCursor: z2.string().nullable()
|
|
57
63
|
});
|
|
@@ -105,12 +111,8 @@ var ProjectConfigSchema = z3.object({
|
|
|
105
111
|
}).strict();
|
|
106
112
|
var SetupSchema = z3.object({
|
|
107
113
|
operationId: z3.uuid(),
|
|
108
|
-
projectName: z3.string().trim().min(1).max(120),
|
|
109
|
-
description: z3.string().max(2e3).default(""),
|
|
110
114
|
publicServerUrl: ServerUrlSchema,
|
|
111
|
-
adminId: NameSchema
|
|
112
|
-
members: z3.array(NameSchema).max(100).default([]),
|
|
113
|
-
sync: SyncPolicySchema.default(DEFAULT_POLICY)
|
|
115
|
+
adminId: NameSchema
|
|
114
116
|
}).strict();
|
|
115
117
|
var SetupStatusSchema = z3.object({
|
|
116
118
|
initialized: z3.boolean()
|
|
@@ -189,113 +191,136 @@ var UserUpdateSchema = z3.object({
|
|
|
189
191
|
}).strict();
|
|
190
192
|
|
|
191
193
|
// packages/protocol/sessions.ts
|
|
194
|
+
import { z as z5 } from "zod";
|
|
195
|
+
|
|
196
|
+
// packages/protocol/session-analysis.ts
|
|
192
197
|
import { z as z4 } from "zod";
|
|
193
|
-
var
|
|
194
|
-
var
|
|
195
|
-
|
|
196
|
-
|
|
198
|
+
var SessionAnalysisKindSchema = z4.enum(["overview", "handoff"]);
|
|
199
|
+
var SessionOverviewOutputSchema = z4.object({ title: z4.string().trim().min(1), summary: z4.string().trim().min(1) }).strict();
|
|
200
|
+
var SessionHandoffOutputSchema = z4.object({ markdown: z4.string().trim().min(1) }).strict();
|
|
201
|
+
var SessionOverviewSchema = SessionOverviewOutputSchema.extend({
|
|
202
|
+
sourceRevision: RevisionSchema,
|
|
203
|
+
sourceHash: HashSchema,
|
|
204
|
+
generatedAt: z4.string(),
|
|
205
|
+
stale: z4.boolean()
|
|
206
|
+
});
|
|
207
|
+
var SessionAnalysisStateSchema = z4.object({
|
|
208
|
+
kind: SessionAnalysisKindSchema,
|
|
209
|
+
status: z4.enum(["absent", "queued", "running", "succeeded", "failed"]),
|
|
210
|
+
available: z4.boolean(),
|
|
211
|
+
currentRevision: RevisionSchema,
|
|
212
|
+
sourceRevision: RevisionSchema.nullable(),
|
|
213
|
+
stale: z4.boolean(),
|
|
214
|
+
result: z4.union([SessionOverviewOutputSchema, SessionHandoffOutputSchema]).nullable(),
|
|
215
|
+
generatedAt: z4.string().nullable(),
|
|
216
|
+
error: z4.string().nullable()
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
// packages/protocol/sessions.ts
|
|
220
|
+
var AgentSchema = z5.enum(["claude-code", "codex", "cursor", "pi", "dsh"]);
|
|
221
|
+
var SessionSnapshotSchema = z5.object({
|
|
222
|
+
formatVersion: z5.literal(1),
|
|
223
|
+
adapterVersion: z5.literal(1),
|
|
197
224
|
agent: AgentSchema,
|
|
198
|
-
nativeId:
|
|
199
|
-
cwd:
|
|
200
|
-
messages:
|
|
201
|
-
|
|
202
|
-
id:
|
|
203
|
-
role:
|
|
204
|
-
text:
|
|
205
|
-
source:
|
|
225
|
+
nativeId: z5.string().min(1).max(512),
|
|
226
|
+
cwd: z5.string().min(1).max(4096),
|
|
227
|
+
messages: z5.array(
|
|
228
|
+
z5.object({
|
|
229
|
+
id: z5.string().min(1).max(1024),
|
|
230
|
+
role: z5.enum(["user", "assistant"]),
|
|
231
|
+
text: z5.string().min(1),
|
|
232
|
+
source: z5.string().min(1).max(1024)
|
|
206
233
|
}).strict()
|
|
207
|
-
)
|
|
234
|
+
)
|
|
208
235
|
}).strict().refine(
|
|
209
236
|
(s) => new Set(s.messages.map((m) => m.id)).size === s.messages.length,
|
|
210
237
|
"Duplicate message IDs."
|
|
211
238
|
);
|
|
212
|
-
var SessionCommitSchema =
|
|
213
|
-
operationId:
|
|
239
|
+
var SessionCommitSchema = z5.object({
|
|
240
|
+
operationId: z5.uuid(),
|
|
214
241
|
sessionKey: HashSchema,
|
|
215
242
|
parentRevision: RevisionSchema,
|
|
216
243
|
blob: BlobRefSchema
|
|
217
244
|
}).strict();
|
|
218
|
-
var SessionInfoSchema =
|
|
245
|
+
var SessionInfoSchema = z5.object({
|
|
219
246
|
owner: NameSchema,
|
|
220
247
|
sessionKey: HashSchema,
|
|
221
248
|
agent: AgentSchema,
|
|
222
|
-
nativeId:
|
|
223
|
-
cwd:
|
|
224
|
-
title:
|
|
249
|
+
nativeId: z5.string(),
|
|
250
|
+
cwd: z5.string(),
|
|
251
|
+
title: z5.string(),
|
|
225
252
|
revision: RevisionSchema,
|
|
226
|
-
updatedAt:
|
|
227
|
-
messageCount:
|
|
228
|
-
blob: BlobRefSchema
|
|
253
|
+
updatedAt: z5.string(),
|
|
254
|
+
messageCount: z5.number().int().nonnegative(),
|
|
255
|
+
blob: BlobRefSchema,
|
|
256
|
+
overview: SessionOverviewSchema.optional()
|
|
229
257
|
});
|
|
230
|
-
var SessionListSchema =
|
|
231
|
-
sessions:
|
|
258
|
+
var SessionListSchema = z5.object({
|
|
259
|
+
sessions: z5.array(SessionInfoSchema),
|
|
232
260
|
nextCursor: RevisionSchema.nullable()
|
|
233
261
|
});
|
|
234
|
-
var SessionStatsSchema =
|
|
235
|
-
totalSessions:
|
|
236
|
-
totalMessages:
|
|
237
|
-
memberCount:
|
|
238
|
-
updatedAt:
|
|
262
|
+
var SessionStatsSchema = z5.object({
|
|
263
|
+
totalSessions: z5.number().int().nonnegative(),
|
|
264
|
+
totalMessages: z5.number().int().nonnegative(),
|
|
265
|
+
memberCount: z5.number().int().nonnegative(),
|
|
266
|
+
updatedAt: z5.string().nullable()
|
|
239
267
|
});
|
|
240
|
-
var SessionScopeSchema =
|
|
268
|
+
var SessionScopeSchema = z5.object({
|
|
241
269
|
owner: NameSchema.optional(),
|
|
242
270
|
sessionKey: HashSchema.optional()
|
|
243
271
|
}).strict().refine((v) => !v.sessionKey || !!v.owner, "A session scope requires an owner.");
|
|
244
|
-
var SessionSearchSchema =
|
|
245
|
-
matches:
|
|
246
|
-
|
|
272
|
+
var SessionSearchSchema = z5.object({
|
|
273
|
+
matches: z5.array(
|
|
274
|
+
z5.object({
|
|
247
275
|
owner: NameSchema,
|
|
248
276
|
sessionKey: HashSchema,
|
|
249
277
|
revision: RevisionSchema,
|
|
250
|
-
messageId:
|
|
251
|
-
role:
|
|
252
|
-
snippet:
|
|
253
|
-
title:
|
|
278
|
+
messageId: z5.string(),
|
|
279
|
+
role: z5.enum(["user", "assistant"]),
|
|
280
|
+
snippet: z5.string(),
|
|
281
|
+
title: z5.string()
|
|
254
282
|
})
|
|
255
283
|
),
|
|
256
284
|
nextCursor: RevisionSchema.nullable()
|
|
257
285
|
});
|
|
258
286
|
|
|
259
287
|
// packages/transport/hub-client.ts
|
|
260
|
-
import { z as
|
|
288
|
+
import { z as z8 } from "zod";
|
|
261
289
|
import { createHash } from "node:crypto";
|
|
262
290
|
|
|
263
291
|
// packages/protocol/index.ts
|
|
264
|
-
import { z as
|
|
292
|
+
import { z as z6 } from "zod";
|
|
265
293
|
var PROTOCOL_VERSION = 1;
|
|
266
|
-
var HealthSchema =
|
|
267
|
-
var ServerInfoSchema =
|
|
268
|
-
name:
|
|
269
|
-
version:
|
|
270
|
-
protocolVersion:
|
|
271
|
-
capabilities:
|
|
294
|
+
var HealthSchema = z6.object({ status: z6.literal("ok") });
|
|
295
|
+
var ServerInfoSchema = z6.object({
|
|
296
|
+
name: z6.literal("teammy"),
|
|
297
|
+
version: z6.string().min(1),
|
|
298
|
+
protocolVersion: z6.literal(PROTOCOL_VERSION),
|
|
299
|
+
capabilities: z6.array(z6.string())
|
|
272
300
|
});
|
|
273
|
-
var ErrorResponseSchema =
|
|
274
|
-
error:
|
|
275
|
-
code:
|
|
276
|
-
message:
|
|
277
|
-
requestId:
|
|
301
|
+
var ErrorResponseSchema = z6.object({
|
|
302
|
+
error: z6.object({
|
|
303
|
+
code: z6.string(),
|
|
304
|
+
message: z6.string(),
|
|
305
|
+
requestId: z6.string()
|
|
278
306
|
})
|
|
279
307
|
});
|
|
280
308
|
|
|
281
309
|
// packages/protocol/shared-files.ts
|
|
282
|
-
import { z as
|
|
310
|
+
import { z as z7 } from "zod";
|
|
283
311
|
var PROJECT_FILE_OWNER = "_project";
|
|
284
|
-
var FileOwnerSchema =
|
|
285
|
-
var SharedPathSchema =
|
|
312
|
+
var FileOwnerSchema = z7.union([NameSchema, z7.literal(PROJECT_FILE_OWNER)]);
|
|
313
|
+
var SharedPathSchema = z7.string().max(255).refine((path) => {
|
|
286
314
|
const directory = path.endsWith("/");
|
|
287
315
|
const name2 = directory ? path.slice(0, -1) : path;
|
|
288
316
|
return FilePathSchema.safeParse(name2).success && !name2.includes("/") && !name2.startsWith(".") && !/^_?teammy/i.test(name2) && (directory ? name2.startsWith("_") : name2.includes("."));
|
|
289
317
|
}, "Use a root file (README.md) or an underscore-prefixed directory (_template/).");
|
|
290
|
-
var SharedFilesConfigSchema =
|
|
318
|
+
var SharedFilesConfigSchema = z7.object({
|
|
291
319
|
maintainer: NameSchema.nullable(),
|
|
292
|
-
paths:
|
|
320
|
+
paths: z7.array(SharedPathSchema).max(30).refine((paths) => new Set(paths.map((p) => p.toLowerCase())).size === paths.length),
|
|
293
321
|
revision: RevisionSchema
|
|
294
322
|
}).strict();
|
|
295
323
|
var SharedFilesUpdateSchema = SharedFilesConfigSchema.omit({ revision: true }).extend({ expectedRevision: RevisionSchema }).strict();
|
|
296
|
-
var SharedFileCommitSchema = FileCommitSchema.safeExtend({
|
|
297
|
-
configRevision: RevisionSchema
|
|
298
|
-
});
|
|
299
324
|
function privateWorkspacePath(path) {
|
|
300
325
|
return path.split("/").some((part) => /^\.teammy(?:[-.]|$)/i.test(part));
|
|
301
326
|
}
|
|
@@ -359,15 +384,23 @@ var HubClient = class {
|
|
|
359
384
|
}
|
|
360
385
|
uploadBlob(hash2, content) {
|
|
361
386
|
if (content.byteLength > MAX_BLOB_BYTES)
|
|
362
|
-
throw new Error("Content exceeds the
|
|
363
|
-
return this.request(
|
|
387
|
+
throw new Error("Content exceeds the binary object size limit.");
|
|
388
|
+
return this.request(
|
|
389
|
+
"PUT",
|
|
390
|
+
`api/v1/blobs/${HashSchema.parse(hash2)}`,
|
|
391
|
+
BlobRefSchema,
|
|
392
|
+
content,
|
|
393
|
+
BLOB_TRANSFER_TIMEOUT_MS
|
|
394
|
+
);
|
|
364
395
|
}
|
|
365
396
|
async downloadBlob(hash2) {
|
|
366
397
|
const { response, signal } = await this.fetchResponse(
|
|
367
398
|
"GET",
|
|
368
|
-
`api/v1/blobs/${HashSchema.parse(hash2)}
|
|
399
|
+
`api/v1/blobs/${HashSchema.parse(hash2)}`,
|
|
400
|
+
void 0,
|
|
401
|
+
BLOB_TRANSFER_TIMEOUT_MS
|
|
369
402
|
);
|
|
370
|
-
if (!response.ok) return this.decode(response, signal,
|
|
403
|
+
if (!response.ok) return this.decode(response, signal, z8.never());
|
|
371
404
|
const reader = response.body?.getReader();
|
|
372
405
|
if (!reader) throw new Error("Server returned no content body.");
|
|
373
406
|
const parts = [];
|
|
@@ -390,7 +423,7 @@ var HubClient = class {
|
|
|
390
423
|
return bytes;
|
|
391
424
|
}
|
|
392
425
|
commitFiles(batch) {
|
|
393
|
-
return this.
|
|
426
|
+
return this.publishFiles(batch);
|
|
394
427
|
}
|
|
395
428
|
fileManifest(owner, revision, after = "", limit = 200) {
|
|
396
429
|
const query = new URLSearchParams({ after, limit: String(limit) });
|
|
@@ -412,7 +445,7 @@ var HubClient = class {
|
|
|
412
445
|
return this.request(
|
|
413
446
|
"PUT",
|
|
414
447
|
"api/v1/devices/self/status",
|
|
415
|
-
|
|
448
|
+
z8.object({ ok: z8.literal(true) }),
|
|
416
449
|
status
|
|
417
450
|
);
|
|
418
451
|
}
|
|
@@ -426,17 +459,27 @@ var HubClient = class {
|
|
|
426
459
|
return this.request("GET", "api/v1/project/shared-files", SharedFilesConfigSchema);
|
|
427
460
|
}
|
|
428
461
|
commitSharedFiles(batch, configRevision) {
|
|
429
|
-
return this.
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
462
|
+
return this.publishFiles({ ...batch, configRevision });
|
|
463
|
+
}
|
|
464
|
+
async publishFiles(publication) {
|
|
465
|
+
const bytes = Buffer.from(JSON.stringify(publication));
|
|
466
|
+
const sha256 = createHash("sha256").update(bytes).digest("hex");
|
|
467
|
+
if ((await this.missingBlobs([sha256])).missing.length) await this.uploadBlob(sha256, bytes);
|
|
468
|
+
return this.request(
|
|
469
|
+
"POST",
|
|
470
|
+
"api/v1/files/publications",
|
|
471
|
+
CommitResultSchema,
|
|
472
|
+
{ manifest: { sha256, size: bytes.byteLength } },
|
|
473
|
+
BLOB_TRANSFER_TIMEOUT_MS
|
|
474
|
+
);
|
|
433
475
|
}
|
|
434
476
|
commitSession(commit) {
|
|
435
477
|
return this.request(
|
|
436
478
|
"POST",
|
|
437
479
|
"api/v1/sessions/commits",
|
|
438
480
|
CommitResultSchema,
|
|
439
|
-
SessionCommitSchema.parse(commit)
|
|
481
|
+
SessionCommitSchema.parse(commit),
|
|
482
|
+
BLOB_TRANSFER_TIMEOUT_MS
|
|
440
483
|
);
|
|
441
484
|
}
|
|
442
485
|
sessions(after = 0, limit = 50) {
|
|
@@ -460,14 +503,14 @@ var HubClient = class {
|
|
|
460
503
|
SessionSearchSchema
|
|
461
504
|
);
|
|
462
505
|
}
|
|
463
|
-
async request(method, path, schema, requestBody) {
|
|
464
|
-
const { response, signal } = await this.fetchResponse(method, path, requestBody);
|
|
506
|
+
async request(method, path, schema, requestBody, timeoutMs = this.timeoutMs) {
|
|
507
|
+
const { response, signal } = await this.fetchResponse(method, path, requestBody, timeoutMs);
|
|
465
508
|
return this.decode(response, signal, schema);
|
|
466
509
|
}
|
|
467
|
-
async fetchResponse(method, path, requestBody) {
|
|
510
|
+
async fetchResponse(method, path, requestBody, timeoutMs = this.timeoutMs) {
|
|
468
511
|
if (this.credentials?.workspaceId && path.startsWith("api/v1/") && !["api/v1/bind", "api/v1/server"].includes(path))
|
|
469
512
|
path = `api/v1/projects/${encodeURIComponent(this.credentials.workspaceId)}/${path.slice("api/v1/".length)}`;
|
|
470
|
-
const timeout = AbortSignal.timeout(
|
|
513
|
+
const timeout = AbortSignal.timeout(timeoutMs);
|
|
471
514
|
const signal = this.abortSignal ? AbortSignal.any([timeout, this.abortSignal]) : timeout;
|
|
472
515
|
const binary = requestBody instanceof Uint8Array;
|
|
473
516
|
const response = await fetch(new URL(path, this.baseUrl), {
|
|
@@ -480,7 +523,9 @@ var HubClient = class {
|
|
|
480
523
|
"x-teammy-device": this.credentials.deviceId
|
|
481
524
|
} : {}
|
|
482
525
|
},
|
|
483
|
-
...requestBody === void 0 ? {} : {
|
|
526
|
+
...requestBody === void 0 ? {} : {
|
|
527
|
+
body: binary ? requestBody : JSON.stringify(requestBody)
|
|
528
|
+
},
|
|
484
529
|
signal,
|
|
485
530
|
redirect: "error"
|
|
486
531
|
});
|
|
@@ -518,11 +563,18 @@ var HubClient = class {
|
|
|
518
563
|
};
|
|
519
564
|
|
|
520
565
|
// apps/client/sessions/command.ts
|
|
521
|
-
import { createHash as
|
|
522
|
-
import { realpath as
|
|
523
|
-
import { homedir as
|
|
524
|
-
import { dirname as
|
|
525
|
-
import { parseArgs } from "node:util";
|
|
566
|
+
import { createHash as createHash8 } from "node:crypto";
|
|
567
|
+
import { realpath as realpath6 } from "node:fs/promises";
|
|
568
|
+
import { homedir as homedir4 } from "node:os";
|
|
569
|
+
import { dirname as dirname9, join as join16, resolve as resolve8 } from "node:path";
|
|
570
|
+
import { parseArgs as parseArgs2 } from "node:util";
|
|
571
|
+
|
|
572
|
+
// packages/protocol/file-publication.ts
|
|
573
|
+
import { z as z9 } from "zod";
|
|
574
|
+
var FilePublicationSchema = FileCommitSchema.safeExtend({
|
|
575
|
+
configRevision: RevisionSchema.optional()
|
|
576
|
+
}).strict();
|
|
577
|
+
var PublishFilesSchema = z9.object({ manifest: BlobRefSchema }).strict();
|
|
526
578
|
|
|
527
579
|
// packages/state/sqlite.ts
|
|
528
580
|
import { chmodSync, mkdirSync } from "node:fs";
|
|
@@ -583,8 +635,14 @@ var FilePublicationState = class {
|
|
|
583
635
|
}
|
|
584
636
|
publication(root) {
|
|
585
637
|
this.db.prepare(`INSERT OR IGNORE INTO ${this.tables.publications}(root) VALUES (?)`).run(root);
|
|
586
|
-
const row = this.db.prepare(
|
|
587
|
-
|
|
638
|
+
const row = this.db.prepare(
|
|
639
|
+
`SELECT revision, initialized, complete FROM ${this.tables.publications} WHERE root=?`
|
|
640
|
+
).get(root);
|
|
641
|
+
return {
|
|
642
|
+
revision: Number(row.revision),
|
|
643
|
+
initialized: Boolean(row.initialized),
|
|
644
|
+
complete: Boolean(row.complete)
|
|
645
|
+
};
|
|
588
646
|
}
|
|
589
647
|
initializePublication(root, revision, files, options = {}) {
|
|
590
648
|
transaction(this.db, () => {
|
|
@@ -592,8 +650,8 @@ var FilePublicationState = class {
|
|
|
592
650
|
if (this.pending(root))
|
|
593
651
|
throw new Error("Cannot replace a baseline with a publication pending.");
|
|
594
652
|
this.db.prepare(
|
|
595
|
-
`INSERT INTO ${this.tables.publications} VALUES (?, ?, 1) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1`
|
|
596
|
-
).run(root, revision);
|
|
653
|
+
`INSERT INTO ${this.tables.publications}(root,revision,initialized,complete) VALUES (?, ?, 1, ?) ON CONFLICT(root) DO UPDATE SET revision=excluded.revision, initialized=1,complete=excluded.complete`
|
|
654
|
+
).run(root, revision, Number(options.complete ?? false));
|
|
597
655
|
this.db.prepare(`DELETE FROM ${this.tables.owner_files} WHERE root=?`).run(root);
|
|
598
656
|
for (const file of files)
|
|
599
657
|
this.db.prepare(`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?)`).run(root, file.path, file.sha256, file.size);
|
|
@@ -606,17 +664,18 @@ var FilePublicationState = class {
|
|
|
606
664
|
}
|
|
607
665
|
pending(root) {
|
|
608
666
|
const row = this.db.prepare(
|
|
609
|
-
`SELECT request_json, attempts, retry_at, last_error FROM ${this.tables.outbox} WHERE root=?`
|
|
667
|
+
`SELECT request_json, attempts, retry_at, last_error, next_index FROM ${this.tables.outbox} WHERE root=?`
|
|
610
668
|
).get(root);
|
|
611
669
|
return row ? {
|
|
612
|
-
batch:
|
|
670
|
+
batch: FilePublicationSchema.parse(JSON.parse(String(row.request_json))),
|
|
671
|
+
nextIndex: Number(row.next_index),
|
|
613
672
|
attempts: Number(row.attempts),
|
|
614
673
|
retryAt: Number(row.retry_at),
|
|
615
674
|
lastError: row.last_error === null ? null : String(row.last_error)
|
|
616
675
|
} : void 0;
|
|
617
676
|
}
|
|
618
677
|
enqueue(root, batch) {
|
|
619
|
-
const fixed =
|
|
678
|
+
const fixed = FilePublicationSchema.parse(batch);
|
|
620
679
|
transaction(this.db, () => {
|
|
621
680
|
const publication = this.publication(root);
|
|
622
681
|
if (!publication.initialized || publication.revision !== fixed.parentRevision)
|
|
@@ -625,6 +684,11 @@ var FilePublicationState = class {
|
|
|
625
684
|
this.db.prepare(`INSERT INTO ${this.tables.outbox}(root, request_json) VALUES (?, ?)`).run(root, JSON.stringify(fixed));
|
|
626
685
|
});
|
|
627
686
|
}
|
|
687
|
+
uploaded(root, operationId, nextIndex) {
|
|
688
|
+
this.db.prepare(
|
|
689
|
+
`UPDATE ${this.tables.outbox} SET next_index=? WHERE root=? AND json_extract(request_json,'$.operationId')=?`
|
|
690
|
+
).run(nextIndex, root, operationId);
|
|
691
|
+
}
|
|
628
692
|
acknowledge(root, result2) {
|
|
629
693
|
transaction(this.db, () => {
|
|
630
694
|
const pending = this.pending(root);
|
|
@@ -638,7 +702,7 @@ var FilePublicationState = class {
|
|
|
638
702
|
`INSERT INTO ${this.tables.owner_files} VALUES (?, ?, ?, ?) ON CONFLICT(root, path) DO UPDATE SET hash=excluded.hash, size=excluded.size`
|
|
639
703
|
).run(root, change.path, change.sha256, change.size);
|
|
640
704
|
}
|
|
641
|
-
this.db.prepare(`UPDATE ${this.tables.publications} SET revision
|
|
705
|
+
this.db.prepare(`UPDATE ${this.tables.publications} SET revision=?,complete=1 WHERE root=?`).run(result2.revision, root);
|
|
642
706
|
this.db.prepare(`DELETE FROM ${this.tables.outbox} WHERE root=?`).run(root);
|
|
643
707
|
});
|
|
644
708
|
}
|
|
@@ -655,6 +719,19 @@ var FilePublicationState = class {
|
|
|
655
719
|
}
|
|
656
720
|
};
|
|
657
721
|
|
|
722
|
+
// apps/client/files/atomic-schema.ts
|
|
723
|
+
var atomicFilesClientMigration = ["", "project_"].map(
|
|
724
|
+
(prefix) => `
|
|
725
|
+
ALTER TABLE ${prefix}publications ADD COLUMN complete INTEGER NOT NULL DEFAULT 0;
|
|
726
|
+
ALTER TABLE ${prefix}outbox ADD COLUMN next_index INTEGER NOT NULL DEFAULT 0;
|
|
727
|
+
-- Old outboxes contain a partial batch, not a complete publication. Reconcile
|
|
728
|
+
-- against the server (including a possibly lost acknowledgement) before rebuilding.
|
|
729
|
+
DELETE FROM ${prefix}outbox;
|
|
730
|
+
UPDATE ${prefix}publications SET initialized=0;
|
|
731
|
+
UPDATE ${prefix}file_index SET dirty=1,event_pending=1,generation=generation+1;
|
|
732
|
+
`
|
|
733
|
+
).join("\n");
|
|
734
|
+
|
|
658
735
|
// apps/client/files/state.ts
|
|
659
736
|
var syncMigration = String.raw`
|
|
660
737
|
CREATE TABLE file_index (
|
|
@@ -872,21 +949,116 @@ var RuntimeState = class {
|
|
|
872
949
|
};
|
|
873
950
|
|
|
874
951
|
// apps/client/state.ts
|
|
875
|
-
import { randomUUID as
|
|
876
|
-
import { join as
|
|
877
|
-
import { z as
|
|
952
|
+
import { randomUUID as randomUUID4 } from "node:crypto";
|
|
953
|
+
import { join as join6, relative as relative2, isAbsolute, sep as sep2 } from "node:path";
|
|
954
|
+
import { z as z11 } from "zod";
|
|
955
|
+
import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
|
|
956
|
+
|
|
957
|
+
// apps/client/runtime/state-access.ts
|
|
958
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
959
|
+
import { chmodSync as chmodSync2, existsSync as existsSync2, mkdirSync as mkdirSync3, readdirSync } from "node:fs";
|
|
960
|
+
import { join as join2 } from "node:path";
|
|
961
|
+
|
|
962
|
+
// apps/client/runtime/metadata.ts
|
|
963
|
+
import { existsSync, mkdirSync as mkdirSync2, readFileSync, renameSync, writeFileSync } from "node:fs";
|
|
964
|
+
import { dirname as dirname2, join } from "node:path";
|
|
965
|
+
import { randomUUID } from "node:crypto";
|
|
966
|
+
function writePrivateJson(path, value) {
|
|
967
|
+
mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
|
|
968
|
+
const temp = path + `.${randomUUID()}.tmp`;
|
|
969
|
+
writeFileSync(temp, JSON.stringify(value, null, 2) + "\n", { mode: 384 });
|
|
970
|
+
renameSync(temp, path);
|
|
971
|
+
}
|
|
972
|
+
function maintenancePath(directory) {
|
|
973
|
+
return join(directory, "runtime", "maintenance.json");
|
|
974
|
+
}
|
|
975
|
+
function readMaintenance(directory) {
|
|
976
|
+
const path = maintenancePath(directory);
|
|
977
|
+
if (!existsSync(path)) return null;
|
|
978
|
+
const value = JSON.parse(readFileSync(path, "utf8"));
|
|
979
|
+
if (value.format !== 1 || !["repair", "upgrade", "paused", "uninstalled"].includes(value.reason))
|
|
980
|
+
throw new Error("Unsupported maintenance record. Inspect teammy logs --all.");
|
|
981
|
+
return value;
|
|
982
|
+
}
|
|
983
|
+
function writeMaintenance(directory, record) {
|
|
984
|
+
writePrivateJson(maintenancePath(directory), {
|
|
985
|
+
...record,
|
|
986
|
+
format: 1,
|
|
987
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
988
|
+
});
|
|
989
|
+
}
|
|
990
|
+
|
|
991
|
+
// apps/client/runtime/state-access.ts
|
|
992
|
+
function stateAccess(directory, exclusive = false) {
|
|
993
|
+
const path = join2(directory, "runtime", "access.sqlite3");
|
|
994
|
+
mkdirSync3(join2(directory, "runtime"), { recursive: true, mode: 448 });
|
|
995
|
+
const db = new DatabaseSync2(path);
|
|
996
|
+
try {
|
|
997
|
+
chmodSync2(path, 384);
|
|
998
|
+
db.exec("PRAGMA busy_timeout=0");
|
|
999
|
+
if (!db.prepare("SELECT 1 FROM sqlite_master WHERE name='access'").get()) {
|
|
1000
|
+
db.exec(
|
|
1001
|
+
"PRAGMA journal_mode=DELETE; CREATE TABLE IF NOT EXISTS access(id INTEGER PRIMARY KEY); INSERT OR IGNORE INTO access VALUES(1)"
|
|
1002
|
+
);
|
|
1003
|
+
}
|
|
1004
|
+
db.exec(exclusive ? "BEGIN EXCLUSIVE" : "BEGIN");
|
|
1005
|
+
db.prepare("SELECT id FROM access").get();
|
|
1006
|
+
if (!exclusive) {
|
|
1007
|
+
const record = readMaintenance(directory);
|
|
1008
|
+
if (record && ["repair", "upgrade"].includes(record.reason))
|
|
1009
|
+
throw new Error("Client maintenance is pending. Run teammy repair.");
|
|
1010
|
+
}
|
|
1011
|
+
return db;
|
|
1012
|
+
} catch (error) {
|
|
1013
|
+
db.close();
|
|
1014
|
+
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
1015
|
+
throw new Error(
|
|
1016
|
+
"Client state is in use. Finish the local foreground command, then run teammy repair."
|
|
1017
|
+
);
|
|
1018
|
+
throw error;
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
function legacyStateLocks(directory) {
|
|
1022
|
+
const locks = [];
|
|
1023
|
+
try {
|
|
1024
|
+
const path = join2(directory, "locks");
|
|
1025
|
+
if (existsSync2(path))
|
|
1026
|
+
for (const file of readdirSync(path)) {
|
|
1027
|
+
if (!/^[a-f0-9]{64}\.sqlite3$/.test(file)) continue;
|
|
1028
|
+
const db = new DatabaseSync2(join2(path, file));
|
|
1029
|
+
locks.push(db);
|
|
1030
|
+
db.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
1031
|
+
}
|
|
1032
|
+
return { close: () => locks.forEach((db) => db.close()) };
|
|
1033
|
+
} catch {
|
|
1034
|
+
locks.forEach((db) => db.close());
|
|
1035
|
+
throw new Error(
|
|
1036
|
+
"An older sync process is still using this client. Stop its foreground command before repair."
|
|
1037
|
+
);
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
function stateVersion(directory) {
|
|
1041
|
+
const path = join2(directory, "client.sqlite3");
|
|
1042
|
+
if (!existsSync2(path)) return 0;
|
|
1043
|
+
const db = new DatabaseSync2(path, { readOnly: true });
|
|
1044
|
+
try {
|
|
1045
|
+
return Number(db.prepare("PRAGMA user_version").get().user_version);
|
|
1046
|
+
} finally {
|
|
1047
|
+
db.close();
|
|
1048
|
+
}
|
|
1049
|
+
}
|
|
878
1050
|
|
|
879
1051
|
// apps/client/workspace.ts
|
|
880
1052
|
import { execFile } from "node:child_process";
|
|
881
1053
|
import { promisify } from "node:util";
|
|
882
1054
|
import { lstat as lstat2, mkdir as mkdir2, realpath as realpath2, rmdir as rmdir2 } from "node:fs/promises";
|
|
883
|
-
import { basename, dirname as
|
|
1055
|
+
import { basename, dirname as dirname4, join as join4, resolve } from "node:path";
|
|
884
1056
|
|
|
885
1057
|
// apps/client/files/local-fs.ts
|
|
886
|
-
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
1058
|
+
import { createHash as createHash2, randomUUID as randomUUID2 } from "node:crypto";
|
|
887
1059
|
import { constants } from "node:fs";
|
|
888
1060
|
import { lstat, mkdir, open, readdir, realpath, rename, rmdir, unlink } from "node:fs/promises";
|
|
889
|
-
import { dirname as
|
|
1061
|
+
import { dirname as dirname3, join as join3, relative, sep } from "node:path";
|
|
890
1062
|
import { setImmediate } from "node:timers/promises";
|
|
891
1063
|
function missing(error) {
|
|
892
1064
|
return error instanceof Error && "code" in error && ["ENOENT", "ENOTDIR"].includes(String(error.code));
|
|
@@ -907,7 +1079,7 @@ async function safePath(root, path, createParents = false) {
|
|
|
907
1079
|
const parts = path.split("/");
|
|
908
1080
|
let parent = root;
|
|
909
1081
|
for (const part of parts.slice(0, -1)) {
|
|
910
|
-
parent =
|
|
1082
|
+
parent = join3(parent, part);
|
|
911
1083
|
if (createParents)
|
|
912
1084
|
await mkdir(parent).catch((error) => {
|
|
913
1085
|
if (error.code !== "EEXIST") throw error;
|
|
@@ -917,7 +1089,7 @@ async function safePath(root, path, createParents = false) {
|
|
|
917
1089
|
if (!stat2.isDirectory())
|
|
918
1090
|
throw Object.assign(new Error(`Parent is not a directory: ${path}`), { code: "ENOTDIR" });
|
|
919
1091
|
}
|
|
920
|
-
return
|
|
1092
|
+
return join3(root, ...parts);
|
|
921
1093
|
}
|
|
922
1094
|
async function metadata(root, path) {
|
|
923
1095
|
try {
|
|
@@ -980,9 +1152,9 @@ async function readStable(root, path) {
|
|
|
980
1152
|
const file = await open(target, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
981
1153
|
try {
|
|
982
1154
|
const before = await file.stat({ bigint: true });
|
|
983
|
-
if (!before.isFile() || before.size > BigInt(
|
|
1155
|
+
if (!before.isFile() || before.size > BigInt(MAX_FILE_BYTES))
|
|
984
1156
|
throw new Error(
|
|
985
|
-
`Only regular files up to ${
|
|
1157
|
+
`Only regular files up to ${MAX_FILE_BYTES} bytes can be synchronized: ${path}`
|
|
986
1158
|
);
|
|
987
1159
|
const buffer = Buffer.alloc(Number(before.size) + 1);
|
|
988
1160
|
let count = 0;
|
|
@@ -1016,7 +1188,7 @@ async function syncDirectory(directory) {
|
|
|
1016
1188
|
}
|
|
1017
1189
|
async function replaceFile(root, path, bytes, expected) {
|
|
1018
1190
|
const target = await safePath(root, path, true);
|
|
1019
|
-
const temporary =
|
|
1191
|
+
const temporary = join3(dirname3(target), `.teammy-${randomUUID2()}.tmp`);
|
|
1020
1192
|
const file = await open(temporary, "wx", 384);
|
|
1021
1193
|
try {
|
|
1022
1194
|
try {
|
|
@@ -1033,11 +1205,11 @@ async function replaceFile(root, path, bytes, expected) {
|
|
|
1033
1205
|
throw new Error(`Local file changed before replacement: ${path}`);
|
|
1034
1206
|
await safePath(root, path);
|
|
1035
1207
|
await rename(temporary, target);
|
|
1036
|
-
let directory =
|
|
1208
|
+
let directory = dirname3(target);
|
|
1037
1209
|
for (; ; ) {
|
|
1038
1210
|
await syncDirectory(directory);
|
|
1039
|
-
if (directory ===
|
|
1040
|
-
directory =
|
|
1211
|
+
if (directory === dirname3(root)) break;
|
|
1212
|
+
directory = dirname3(directory);
|
|
1041
1213
|
}
|
|
1042
1214
|
} finally {
|
|
1043
1215
|
await unlink(temporary).catch((error) => {
|
|
@@ -1052,16 +1224,16 @@ async function removeFile(root, path, expected) {
|
|
|
1052
1224
|
throw new Error(`Local file changed before deletion: ${path}`);
|
|
1053
1225
|
const target = await safePath(root, path);
|
|
1054
1226
|
await unlink(target);
|
|
1055
|
-
await syncDirectory(
|
|
1056
|
-
let parent =
|
|
1227
|
+
await syncDirectory(dirname3(target));
|
|
1228
|
+
let parent = dirname3(target);
|
|
1057
1229
|
while (parent !== root) {
|
|
1058
1230
|
try {
|
|
1059
1231
|
await rmdir(parent);
|
|
1060
|
-
await syncDirectory(
|
|
1232
|
+
await syncDirectory(dirname3(parent));
|
|
1061
1233
|
} catch {
|
|
1062
1234
|
break;
|
|
1063
1235
|
}
|
|
1064
|
-
parent =
|
|
1236
|
+
parent = dirname3(parent);
|
|
1065
1237
|
}
|
|
1066
1238
|
}
|
|
1067
1239
|
|
|
@@ -1069,7 +1241,7 @@ async function removeFile(root, path, expected) {
|
|
|
1069
1241
|
var exec = promisify(execFile);
|
|
1070
1242
|
async function workspacePath(input) {
|
|
1071
1243
|
const path = resolve(input);
|
|
1072
|
-
return
|
|
1244
|
+
return join4(await realpath2(dirname4(path)), basename(path));
|
|
1073
1245
|
}
|
|
1074
1246
|
async function directoryIdentity(root) {
|
|
1075
1247
|
const stat2 = await lstat2(root, { bigint: true });
|
|
@@ -1086,18 +1258,18 @@ async function verifyWorkspace(state, root) {
|
|
|
1086
1258
|
);
|
|
1087
1259
|
}
|
|
1088
1260
|
async function ignorePlan(root) {
|
|
1089
|
-
const parent =
|
|
1261
|
+
const parent = dirname4(root), name2 = basename(root);
|
|
1090
1262
|
if (/[\r\n]/.test(name2)) throw new Error("Workspace name cannot contain a newline.");
|
|
1091
1263
|
let repo = false;
|
|
1092
|
-
for (let p = parent; ; p =
|
|
1264
|
+
for (let p = parent; ; p = dirname4(p)) {
|
|
1093
1265
|
try {
|
|
1094
|
-
await lstat2(
|
|
1266
|
+
await lstat2(join4(p, ".git"));
|
|
1095
1267
|
repo = true;
|
|
1096
1268
|
break;
|
|
1097
1269
|
} catch (e) {
|
|
1098
1270
|
if (!missing(e)) throw e;
|
|
1099
1271
|
}
|
|
1100
|
-
if (p ===
|
|
1272
|
+
if (p === dirname4(p)) break;
|
|
1101
1273
|
}
|
|
1102
1274
|
if (repo) {
|
|
1103
1275
|
let tracked;
|
|
@@ -1179,8 +1351,8 @@ function completePreparation(state, root) {
|
|
|
1179
1351
|
}
|
|
1180
1352
|
|
|
1181
1353
|
// apps/client/runtime/log.ts
|
|
1182
|
-
import { appendFileSync, mkdirSync as
|
|
1183
|
-
import { join as
|
|
1354
|
+
import { appendFileSync, mkdirSync as mkdirSync4, renameSync as renameSync2, statSync, unlinkSync, readFileSync as readFileSync2 } from "node:fs";
|
|
1355
|
+
import { join as join5 } from "node:path";
|
|
1184
1356
|
import { createHash as createHash3 } from "node:crypto";
|
|
1185
1357
|
function rootKey(root) {
|
|
1186
1358
|
return createHash3("sha256").update(root).digest("hex").slice(0, 16);
|
|
@@ -1196,9 +1368,9 @@ function diagnostic(error) {
|
|
|
1196
1368
|
var ClientLog = class {
|
|
1197
1369
|
constructor(directory, limit = 1024 * 1024) {
|
|
1198
1370
|
this.limit = limit;
|
|
1199
|
-
const logs =
|
|
1200
|
-
|
|
1201
|
-
this.path =
|
|
1371
|
+
const logs = join5(directory, "logs");
|
|
1372
|
+
mkdirSync4(logs, { recursive: true, mode: 448 });
|
|
1373
|
+
this.path = join5(logs, "client.jsonl");
|
|
1202
1374
|
}
|
|
1203
1375
|
limit;
|
|
1204
1376
|
path;
|
|
@@ -1215,10 +1387,10 @@ var ClientLog = class {
|
|
|
1215
1387
|
} catch {
|
|
1216
1388
|
}
|
|
1217
1389
|
try {
|
|
1218
|
-
|
|
1390
|
+
renameSync2(this.path + ".1", this.path + ".2");
|
|
1219
1391
|
} catch {
|
|
1220
1392
|
}
|
|
1221
|
-
|
|
1393
|
+
renameSync2(this.path, this.path + ".1");
|
|
1222
1394
|
}
|
|
1223
1395
|
appendFileSync(
|
|
1224
1396
|
this.path,
|
|
@@ -1237,7 +1409,7 @@ var ClientLog = class {
|
|
|
1237
1409
|
const lines = [];
|
|
1238
1410
|
for (const suffix of [".2", ".1", ""])
|
|
1239
1411
|
try {
|
|
1240
|
-
for (const line of
|
|
1412
|
+
for (const line of readFileSync2(this.path + suffix, "utf8").split("\n")) {
|
|
1241
1413
|
if (!line) continue;
|
|
1242
1414
|
try {
|
|
1243
1415
|
const item = JSON.parse(line);
|
|
@@ -1255,50 +1427,50 @@ var ClientLog = class {
|
|
|
1255
1427
|
import { setTimeout as setTimeout2 } from "node:timers/promises";
|
|
1256
1428
|
|
|
1257
1429
|
// packages/protocol/devices.ts
|
|
1258
|
-
import { z as
|
|
1259
|
-
var CredentialSchema =
|
|
1260
|
-
var EnrollmentSchema =
|
|
1261
|
-
operationId:
|
|
1430
|
+
import { z as z10 } from "zod";
|
|
1431
|
+
var CredentialSchema = z10.string().regex(/^tmy_[a-f0-9]{64}$/);
|
|
1432
|
+
var EnrollmentSchema = z10.object({
|
|
1433
|
+
operationId: z10.uuid(),
|
|
1262
1434
|
projectId: NameSchema,
|
|
1263
1435
|
memberId: NameSchema,
|
|
1264
1436
|
deviceId: DeviceIdSchema,
|
|
1265
1437
|
credential: CredentialSchema
|
|
1266
1438
|
}).strict();
|
|
1267
|
-
var EffectivePolicySchema =
|
|
1268
|
-
checkIntervalSeconds:
|
|
1269
|
-
fileSettleSeconds:
|
|
1439
|
+
var EffectivePolicySchema = z10.object({
|
|
1440
|
+
checkIntervalSeconds: z10.number().min(1).max(86400),
|
|
1441
|
+
fileSettleSeconds: z10.number().min(0).max(86400)
|
|
1270
1442
|
}).strict();
|
|
1271
|
-
var SyncSummarySchema =
|
|
1272
|
-
checkedAt:
|
|
1273
|
-
published:
|
|
1274
|
-
appliedFiles:
|
|
1275
|
-
pendingFiles:
|
|
1276
|
-
sessionSources:
|
|
1277
|
-
errors:
|
|
1443
|
+
var SyncSummarySchema = z10.object({
|
|
1444
|
+
checkedAt: z10.string(),
|
|
1445
|
+
published: z10.boolean(),
|
|
1446
|
+
appliedFiles: z10.number().int().nonnegative(),
|
|
1447
|
+
pendingFiles: z10.number().int().nonnegative(),
|
|
1448
|
+
sessionSources: z10.number().int().nonnegative(),
|
|
1449
|
+
errors: z10.array(z10.string().max(1e3)).max(20)
|
|
1278
1450
|
}).strict();
|
|
1279
|
-
var DeviceStatusSchema =
|
|
1280
|
-
clientVersion:
|
|
1451
|
+
var DeviceStatusSchema = z10.object({
|
|
1452
|
+
clientVersion: z10.string().max(64),
|
|
1281
1453
|
receivedRevision: RevisionSchema.nullable(),
|
|
1282
1454
|
appliedRevision: RevisionSchema.nullable(),
|
|
1283
1455
|
effectivePolicy: EffectivePolicySchema,
|
|
1284
|
-
overrides:
|
|
1285
|
-
configError:
|
|
1286
|
-
reportError:
|
|
1456
|
+
overrides: z10.array(z10.enum(["checkIntervalSeconds", "fileSettleSeconds"])).max(2),
|
|
1457
|
+
configError: z10.string().max(2e3).nullable(),
|
|
1458
|
+
reportError: z10.string().max(2e3).nullable(),
|
|
1287
1459
|
lastSync: SyncSummarySchema.nullable()
|
|
1288
1460
|
}).strict();
|
|
1289
|
-
var WebDevicesSchema =
|
|
1290
|
-
devices:
|
|
1291
|
-
|
|
1461
|
+
var WebDevicesSchema = z10.object({
|
|
1462
|
+
devices: z10.array(
|
|
1463
|
+
z10.object({
|
|
1292
1464
|
memberId: NameSchema,
|
|
1293
|
-
bound:
|
|
1294
|
-
lastSeenAt:
|
|
1465
|
+
bound: z10.boolean(),
|
|
1466
|
+
lastSeenAt: z10.string().nullable(),
|
|
1295
1467
|
status: DeviceStatusSchema.nullable()
|
|
1296
1468
|
})
|
|
1297
1469
|
)
|
|
1298
1470
|
});
|
|
1299
1471
|
|
|
1300
1472
|
// apps/client/project-state.ts
|
|
1301
|
-
import { randomBytes, randomUUID as
|
|
1473
|
+
import { randomBytes, randomUUID as randomUUID3 } from "node:crypto";
|
|
1302
1474
|
var projectClientMigration = String.raw`
|
|
1303
1475
|
CREATE TABLE client_enrollments(root TEXT PRIMARY KEY REFERENCES connections(root),server_url TEXT NOT NULL,request_json TEXT NOT NULL);
|
|
1304
1476
|
CREATE TABLE client_project_config(root TEXT PRIMARY KEY REFERENCES connections(root),config_json TEXT NOT NULL,descriptor_hash TEXT NOT NULL);
|
|
@@ -1322,7 +1494,7 @@ var ClientProjectState = class {
|
|
|
1322
1494
|
return request2;
|
|
1323
1495
|
}
|
|
1324
1496
|
const request = {
|
|
1325
|
-
operationId:
|
|
1497
|
+
operationId: randomUUID3(),
|
|
1326
1498
|
memberId,
|
|
1327
1499
|
projectId,
|
|
1328
1500
|
deviceId,
|
|
@@ -1495,6 +1667,20 @@ function readControlStatus(state, root) {
|
|
|
1495
1667
|
return row ? DeviceStatusSchema.parse(JSON.parse(String(row.status_json))) : null;
|
|
1496
1668
|
}
|
|
1497
1669
|
|
|
1670
|
+
// apps/client/sessions/versions.ts
|
|
1671
|
+
var parserVersions = {
|
|
1672
|
+
"claude-code": 1,
|
|
1673
|
+
codex: 1,
|
|
1674
|
+
cursor: 1,
|
|
1675
|
+
pi: 2,
|
|
1676
|
+
dsh: 1
|
|
1677
|
+
};
|
|
1678
|
+
var sessionParserMigration = `
|
|
1679
|
+
ALTER TABLE session_sources ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0;
|
|
1680
|
+
ALTER TABLE session_publications ADD COLUMN pending_parser_version INTEGER NOT NULL DEFAULT 0;
|
|
1681
|
+
ALTER TABLE session_discovery_cache ADD COLUMN parser_version INTEGER NOT NULL DEFAULT 0;
|
|
1682
|
+
`;
|
|
1683
|
+
|
|
1498
1684
|
// apps/client/sessions/state.ts
|
|
1499
1685
|
var sessionClientMigration = String.raw`
|
|
1500
1686
|
CREATE TABLE session_sources (
|
|
@@ -1554,12 +1740,12 @@ var SessionState = class {
|
|
|
1554
1740
|
get(path) {
|
|
1555
1741
|
return this.sources(path)[0];
|
|
1556
1742
|
}
|
|
1557
|
-
sources(path, limit = 2147483647) {
|
|
1743
|
+
sources(path, limit = 2147483647, before = Number.MAX_SAFE_INTEGER) {
|
|
1558
1744
|
return this.state.db.prepare(
|
|
1559
|
-
`SELECT s.*, p.revision,p.hash,p.selected_path,p.pending_json,p.pending_signature,p.pending_path,p.attempts,p.retry_at
|
|
1745
|
+
`SELECT s.*, p.revision,p.hash,p.selected_path,p.pending_json,p.pending_signature,p.pending_path,p.pending_parser_version,p.attempts,p.retry_at
|
|
1560
1746
|
FROM session_sources s LEFT JOIN session_publications p USING(root,session_key)
|
|
1561
|
-
WHERE s.root=? ${path === void 0 ? "" : "AND s.path=?"} ORDER BY s.checked_at, s.path LIMIT ?`
|
|
1562
|
-
).all(this.root, ...path === void 0 ? [] : [path], limit).map((row) => ({
|
|
1747
|
+
WHERE s.root=? AND s.checked_at<? ${path === void 0 ? "" : "AND s.path=?"} ORDER BY s.checked_at, s.path LIMIT ?`
|
|
1748
|
+
).all(this.root, before, ...path === void 0 ? [] : [path], limit).map((row) => ({
|
|
1563
1749
|
path: String(row.path),
|
|
1564
1750
|
agent: row.agent,
|
|
1565
1751
|
projectDir: String(row.project_dir),
|
|
@@ -1569,6 +1755,8 @@ var SessionState = class {
|
|
|
1569
1755
|
signature: row.signature,
|
|
1570
1756
|
checkedAt: Number(row.checked_at),
|
|
1571
1757
|
verifiedAt: Number(row.verified_at),
|
|
1758
|
+
parserVersion: Number(row.parser_version),
|
|
1759
|
+
pendingParserVersion: Number(row.pending_parser_version ?? 0),
|
|
1572
1760
|
lastError: row.last_error,
|
|
1573
1761
|
adoptExisting: Boolean(row.adopt_existing),
|
|
1574
1762
|
pending: row.pending_json ? SessionCommitSchema.parse(JSON.parse(String(row.pending_json))) : null,
|
|
@@ -1607,21 +1795,28 @@ var SessionState = class {
|
|
|
1607
1795
|
}
|
|
1608
1796
|
stage(path, request, signature2) {
|
|
1609
1797
|
this.state.db.prepare(
|
|
1610
|
-
`UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,attempts=0,retry_at=0
|
|
1798
|
+
`UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,pending_parser_version=?,attempts=0,retry_at=0
|
|
1611
1799
|
WHERE root=? AND session_key=? AND pending_json IS NULL`
|
|
1612
|
-
).run(
|
|
1800
|
+
).run(
|
|
1801
|
+
JSON.stringify(request),
|
|
1802
|
+
signature2,
|
|
1803
|
+
path,
|
|
1804
|
+
parserVersions[this.get(path).agent],
|
|
1805
|
+
this.root,
|
|
1806
|
+
request.sessionKey
|
|
1807
|
+
);
|
|
1613
1808
|
}
|
|
1614
|
-
acknowledge(path, revision, hash2, signature2) {
|
|
1809
|
+
acknowledge(path, revision, hash2, signature2, parserVersion) {
|
|
1615
1810
|
const source = this.get(path);
|
|
1616
1811
|
this.state.db.prepare(
|
|
1617
1812
|
`UPDATE session_publications SET revision=?,hash=?,selected_path=?,pending_json=NULL,pending_signature=NULL,pending_path=NULL,attempts=0,retry_at=0 WHERE root=? AND session_key=?`
|
|
1618
1813
|
).run(revision, hash2, path, this.root, source.key);
|
|
1619
|
-
this.observed(path, signature2);
|
|
1814
|
+
this.observed(path, signature2, parserVersion);
|
|
1620
1815
|
}
|
|
1621
|
-
observed(path, signature2) {
|
|
1816
|
+
observed(path, signature2, parserVersion = parserVersions[this.get(path).agent]) {
|
|
1622
1817
|
this.state.db.prepare(
|
|
1623
|
-
"UPDATE session_sources SET signature=?,verified_at=?,last_error=NULL,adopt_existing=0 WHERE root=? AND path=?"
|
|
1624
|
-
).run(signature2, Date.now(), this.root, path);
|
|
1818
|
+
"UPDATE session_sources SET signature=?,verified_at=?,parser_version=?,last_error=NULL,adopt_existing=0 WHERE root=? AND path=?"
|
|
1819
|
+
).run(signature2, Date.now(), parserVersion, this.root, path);
|
|
1625
1820
|
}
|
|
1626
1821
|
baseline(path, key, revision) {
|
|
1627
1822
|
this.state.db.prepare(
|
|
@@ -1643,10 +1838,12 @@ var SessionState = class {
|
|
|
1643
1838
|
this.checked(source.path, error);
|
|
1644
1839
|
}
|
|
1645
1840
|
discover(agent, directory, projectDir) {
|
|
1841
|
+
const prior = this.directories().find((item) => item.directory === directory);
|
|
1842
|
+
if (prior?.agent === agent && prior.projectDir === projectDir) return;
|
|
1646
1843
|
this.state.db.prepare(
|
|
1647
1844
|
"INSERT INTO session_discovery VALUES (?,?,?,?) ON CONFLICT(root,directory) DO UPDATE SET agent=excluded.agent,project_dir=excluded.project_dir"
|
|
1648
1845
|
).run(this.root, directory, agent, projectDir);
|
|
1649
|
-
this.state.db.prepare("DELETE FROM session_discovery_cache WHERE root=?").run(this.root);
|
|
1846
|
+
this.state.db.prepare("DELETE FROM session_discovery_cache WHERE root=? AND substr(path,1,?)=?").run(this.root, directory.length + 1, directory + "/");
|
|
1650
1847
|
}
|
|
1651
1848
|
receivedHook(agent) {
|
|
1652
1849
|
this.state.db.prepare(
|
|
@@ -1667,33 +1864,75 @@ var SessionState = class {
|
|
|
1667
1864
|
|
|
1668
1865
|
// apps/client/state.ts
|
|
1669
1866
|
var LocalBindingSchema = BindingSchema.extend({
|
|
1670
|
-
root:
|
|
1671
|
-
serverUrl:
|
|
1672
|
-
token:
|
|
1867
|
+
root: z11.string(),
|
|
1868
|
+
serverUrl: z11.string(),
|
|
1869
|
+
token: z11.string()
|
|
1673
1870
|
});
|
|
1674
|
-
var
|
|
1675
|
-
|
|
1676
|
-
const db = openDatabase(join4(directory, "client.sqlite3"), [
|
|
1677
|
-
String.raw`
|
|
1871
|
+
var clientMigrations = [
|
|
1872
|
+
String.raw`
|
|
1678
1873
|
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1679
1874
|
`,
|
|
1680
|
-
|
|
1875
|
+
String.raw`
|
|
1681
1876
|
CREATE TABLE publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
|
|
1682
1877
|
CREATE TABLE owner_files (root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, hash TEXT NOT NULL, size INTEGER NOT NULL, PRIMARY KEY(root, path));
|
|
1683
1878
|
CREATE TABLE outbox (root TEXT PRIMARY KEY REFERENCES connections(root), request_json TEXT NOT NULL, attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0, last_error TEXT);
|
|
1684
1879
|
`,
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1688
|
-
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
|
|
1692
|
-
|
|
1880
|
+
syncMigration,
|
|
1881
|
+
sessionClientMigration,
|
|
1882
|
+
projectClientMigration,
|
|
1883
|
+
controlClientMigration,
|
|
1884
|
+
runtimeMigration,
|
|
1885
|
+
projectFilesClientMigration,
|
|
1886
|
+
sessionSourcesMigration,
|
|
1887
|
+
atomicFilesClientMigration,
|
|
1888
|
+
sessionParserMigration
|
|
1889
|
+
];
|
|
1890
|
+
var clientSchemaVersion = clientMigrations.length;
|
|
1891
|
+
var ClientState = class extends FilePublicationState {
|
|
1892
|
+
access;
|
|
1893
|
+
constructor(directory, options = {}) {
|
|
1894
|
+
let access2;
|
|
1895
|
+
let db;
|
|
1896
|
+
try {
|
|
1897
|
+
if (!options.maintenance) access2 = stateAccess(directory);
|
|
1898
|
+
const version = stateVersion(directory);
|
|
1899
|
+
if (version > clientSchemaVersion)
|
|
1900
|
+
throw new Error(
|
|
1901
|
+
"Database was created by a newer Teammy version. Local stop and logs remain available."
|
|
1902
|
+
);
|
|
1903
|
+
if (version > 0 && version < clientSchemaVersion)
|
|
1904
|
+
throw new Error("Client state needs an upgrade. Run teammy repair.");
|
|
1905
|
+
if (options.readOnly) {
|
|
1906
|
+
if (!version) throw new Error("No client state is available.");
|
|
1907
|
+
db = new DatabaseSync3(join6(directory, "client.sqlite3"), { readOnly: true });
|
|
1908
|
+
} else if (!version) {
|
|
1909
|
+
access2?.close();
|
|
1910
|
+
access2 = void 0;
|
|
1911
|
+
const exclusive = options.maintenance ? void 0 : stateAccess(directory, true);
|
|
1912
|
+
try {
|
|
1913
|
+
db = openDatabase(join6(directory, "client.sqlite3"), clientMigrations);
|
|
1914
|
+
} finally {
|
|
1915
|
+
exclusive?.close();
|
|
1916
|
+
}
|
|
1917
|
+
if (!options.maintenance) access2 = stateAccess(directory);
|
|
1918
|
+
} else {
|
|
1919
|
+
db = new DatabaseSync3(join6(directory, "client.sqlite3"));
|
|
1920
|
+
db.exec("PRAGMA busy_timeout=5000; PRAGMA foreign_keys=ON; PRAGMA synchronous=FULL;");
|
|
1921
|
+
}
|
|
1922
|
+
} catch (error) {
|
|
1923
|
+
db?.close();
|
|
1924
|
+
access2?.close();
|
|
1925
|
+
throw error;
|
|
1926
|
+
}
|
|
1693
1927
|
super(db);
|
|
1928
|
+
this.access = access2;
|
|
1694
1929
|
}
|
|
1695
1930
|
close() {
|
|
1696
|
-
|
|
1931
|
+
try {
|
|
1932
|
+
this.db.close();
|
|
1933
|
+
} finally {
|
|
1934
|
+
this.access?.close();
|
|
1935
|
+
}
|
|
1697
1936
|
}
|
|
1698
1937
|
assertRootAvailable(root) {
|
|
1699
1938
|
for (const row of this.db.prepare("SELECT root FROM connections").all()) {
|
|
@@ -1709,7 +1948,7 @@ var ClientState = class extends FilePublicationState {
|
|
|
1709
1948
|
}
|
|
1710
1949
|
deviceFor(root) {
|
|
1711
1950
|
this.assertRootAvailable(root);
|
|
1712
|
-
this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root,
|
|
1951
|
+
this.db.prepare("INSERT OR IGNORE INTO connections (root, device_id) VALUES (?, ?)").run(root, randomUUID4());
|
|
1713
1952
|
return String(
|
|
1714
1953
|
this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
|
|
1715
1954
|
);
|
|
@@ -1754,11 +1993,11 @@ async function stateLocation(root, directory) {
|
|
|
1754
1993
|
// apps/client/sessions/setup.ts
|
|
1755
1994
|
import { execFile as execFile2 } from "node:child_process";
|
|
1756
1995
|
import { promisify as promisify2 } from "node:util";
|
|
1757
|
-
import { access, mkdir as
|
|
1996
|
+
import { access, mkdir as mkdir5, readFile as readFile2, writeFile, realpath as realpath4 } from "node:fs/promises";
|
|
1758
1997
|
import { constants as constants2 } from "node:fs";
|
|
1759
1998
|
import { homedir } from "node:os";
|
|
1760
|
-
import { dirname as
|
|
1761
|
-
import { createHash as
|
|
1999
|
+
import { dirname as dirname6, join as join10, resolve as resolve4 } from "node:path";
|
|
2000
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
1762
2001
|
|
|
1763
2002
|
// apps/client/sessions/normalize.ts
|
|
1764
2003
|
function object(value) {
|
|
@@ -1792,6 +2031,9 @@ var JsonlNormalizer = class {
|
|
|
1792
2031
|
nativeId;
|
|
1793
2032
|
cwd;
|
|
1794
2033
|
turn = "legacy";
|
|
2034
|
+
piParents = /* @__PURE__ */ new Map();
|
|
2035
|
+
piLeaf = null;
|
|
2036
|
+
piTree = false;
|
|
1795
2037
|
candidates = [];
|
|
1796
2038
|
add(role, content, id, line, visible = false) {
|
|
1797
2039
|
if (role !== "user" && role !== "assistant") return;
|
|
@@ -1839,8 +2081,11 @@ var JsonlNormalizer = class {
|
|
|
1839
2081
|
this.add(payload.role, payload.content, payload.id, line);
|
|
1840
2082
|
}
|
|
1841
2083
|
} else if (this.agent === "cursor") {
|
|
1842
|
-
const message2 = object(row.message);
|
|
1843
2084
|
if (row.isSidechain === true) return;
|
|
2085
|
+
const sessionId = row.sessionId ?? row.session_id;
|
|
2086
|
+
if (typeof sessionId === "string" && this.nativeId && sessionId !== this.nativeId)
|
|
2087
|
+
throw new Error("Mixed Cursor session identities.");
|
|
2088
|
+
const message2 = object(row.message);
|
|
1844
2089
|
this.add(row.role ?? message2.role, message2.content ?? row.content, row.id ?? row.uuid, line);
|
|
1845
2090
|
} else if (this.agent === "pi") {
|
|
1846
2091
|
if (row.type === "session") {
|
|
@@ -1849,6 +2094,14 @@ var JsonlNormalizer = class {
|
|
|
1849
2094
|
this.nativeId = row.id;
|
|
1850
2095
|
this.cwd = row.cwd;
|
|
1851
2096
|
}
|
|
2097
|
+
if (row.type !== "session" && typeof row.id === "string") {
|
|
2098
|
+
this.piTree ||= Object.hasOwn(row, "parentId");
|
|
2099
|
+
this.piParents.set(
|
|
2100
|
+
row.id,
|
|
2101
|
+
typeof row.parentId === "string" ? row.parentId : row.parentId === null ? null : this.piLeaf
|
|
2102
|
+
);
|
|
2103
|
+
this.piLeaf = row.id;
|
|
2104
|
+
}
|
|
1852
2105
|
if (row.type === "message") {
|
|
1853
2106
|
const message2 = object(row.message);
|
|
1854
2107
|
this.add(message2.role, message2.content, row.id, line);
|
|
@@ -1876,9 +2129,25 @@ var JsonlNormalizer = class {
|
|
|
1876
2129
|
}
|
|
1877
2130
|
}
|
|
1878
2131
|
finish() {
|
|
2132
|
+
let piBranch;
|
|
2133
|
+
if (this.agent === "pi" && this.piTree) {
|
|
2134
|
+
piBranch = /* @__PURE__ */ new Set();
|
|
2135
|
+
let id = this.piLeaf;
|
|
2136
|
+
while (id && !piBranch.has(id)) {
|
|
2137
|
+
piBranch.add(id);
|
|
2138
|
+
id = this.piParents.get(id) ?? null;
|
|
2139
|
+
}
|
|
2140
|
+
}
|
|
1879
2141
|
const visibleGroups = new Set(this.candidates.filter((m) => m.visible).map((m) => m.group));
|
|
1880
2142
|
const ids = /* @__PURE__ */ new Map();
|
|
1881
|
-
const
|
|
2143
|
+
const seen = /* @__PURE__ */ new Set();
|
|
2144
|
+
const messages = this.candidates.filter((m) => !piBranch || typeof m.id === "string" && piBranch.has(m.id)).filter((m) => m.visible || !visibleGroups.has(m.group)).filter((m) => {
|
|
2145
|
+
if (typeof m.id !== "string" || !m.id) return true;
|
|
2146
|
+
const identity = JSON.stringify([m.id, m.role, m.text]);
|
|
2147
|
+
if (seen.has(identity)) return false;
|
|
2148
|
+
seen.add(identity);
|
|
2149
|
+
return true;
|
|
2150
|
+
}).map((m) => {
|
|
1882
2151
|
const base = typeof m.id === "string" && m.id ? m.id : `line:${m.line}`;
|
|
1883
2152
|
const count = (ids.get(base) ?? 0) + 1;
|
|
1884
2153
|
ids.set(base, count);
|
|
@@ -1895,6 +2164,163 @@ var JsonlNormalizer = class {
|
|
|
1895
2164
|
}
|
|
1896
2165
|
};
|
|
1897
2166
|
|
|
2167
|
+
// apps/client/sessions/registration.ts
|
|
2168
|
+
import { existsSync as existsSync3, readFileSync as readFileSync3, readdirSync as readdirSync2 } from "node:fs";
|
|
2169
|
+
import { join as join7 } from "node:path";
|
|
2170
|
+
var registrationPath = (directory, root) => join7(directory, "hook-registrations", rootKey(root) + ".json");
|
|
2171
|
+
function readRegistration(directory, root) {
|
|
2172
|
+
const path = registrationPath(directory, root);
|
|
2173
|
+
if (!existsSync3(path)) return null;
|
|
2174
|
+
const value = JSON.parse(readFileSync3(path, "utf8"));
|
|
2175
|
+
if (value.format !== 1 || value.root !== root || !Array.isArray(value.configs))
|
|
2176
|
+
throw new Error("Invalid Hook registration.");
|
|
2177
|
+
return value;
|
|
2178
|
+
}
|
|
2179
|
+
function saveRegistration(directory, value) {
|
|
2180
|
+
writePrivateJson(registrationPath(directory, value.root), value);
|
|
2181
|
+
}
|
|
2182
|
+
function registrations(directory, onError) {
|
|
2183
|
+
const path = join7(directory, "hook-registrations");
|
|
2184
|
+
if (!existsSync3(path)) return [];
|
|
2185
|
+
return readdirSync2(path).filter((name2) => /^[a-f0-9]{16}\.json$/.test(name2)).flatMap((name2) => {
|
|
2186
|
+
try {
|
|
2187
|
+
const value = JSON.parse(readFileSync3(join7(path, name2), "utf8"));
|
|
2188
|
+
const registration = readRegistration(directory, value.root);
|
|
2189
|
+
if (!registration) throw new Error("Missing Hook registration.");
|
|
2190
|
+
return [registration];
|
|
2191
|
+
} catch (error) {
|
|
2192
|
+
onError(join7(path, name2), error);
|
|
2193
|
+
return [];
|
|
2194
|
+
}
|
|
2195
|
+
});
|
|
2196
|
+
}
|
|
2197
|
+
|
|
2198
|
+
// apps/client/root-lock.ts
|
|
2199
|
+
import { createHash as createHash5 } from "node:crypto";
|
|
2200
|
+
import { join as join8 } from "node:path";
|
|
2201
|
+
function acquireRootLock(root, stateDirectory) {
|
|
2202
|
+
let lock;
|
|
2203
|
+
try {
|
|
2204
|
+
lock = openDatabase(
|
|
2205
|
+
join8(stateDirectory, "locks", `${createHash5("sha256").update(root).digest("hex")}.sqlite3`),
|
|
2206
|
+
[],
|
|
2207
|
+
0
|
|
2208
|
+
);
|
|
2209
|
+
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
2210
|
+
return lock;
|
|
2211
|
+
} catch (error) {
|
|
2212
|
+
lock?.close();
|
|
2213
|
+
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
2214
|
+
throw new Error("Another sync or connect process is using this root.");
|
|
2215
|
+
throw error;
|
|
2216
|
+
}
|
|
2217
|
+
}
|
|
2218
|
+
|
|
2219
|
+
// apps/client/sessions/setup.ts
|
|
2220
|
+
import { DatabaseSync as DatabaseSync4 } from "node:sqlite";
|
|
2221
|
+
|
|
2222
|
+
// apps/client/sessions/pi-extension.ts
|
|
2223
|
+
import { mkdir as mkdir4, readFile } from "node:fs/promises";
|
|
2224
|
+
import { dirname as dirname5, basename as basename2, join as join9, resolve as resolve3 } from "node:path";
|
|
2225
|
+
var marker = (directory) => `// Teammy Pi collection ${rootKey(directory)}`;
|
|
2226
|
+
function piPath(path, home, cwd) {
|
|
2227
|
+
return resolve3(
|
|
2228
|
+
cwd,
|
|
2229
|
+
path === "~" ? home : path.startsWith("~/") ? join9(home, path.slice(2)) : path
|
|
2230
|
+
);
|
|
2231
|
+
}
|
|
2232
|
+
async function piDiscoveryDirectories(agentDirectory, project, home, env) {
|
|
2233
|
+
let configured;
|
|
2234
|
+
for (const path of [join9(agentDirectory, "settings.json"), join9(project, ".pi/settings.json")]) {
|
|
2235
|
+
try {
|
|
2236
|
+
const settings = JSON.parse(await readFile(path, "utf8"));
|
|
2237
|
+
if (typeof settings?.sessionDir === "string" && settings.sessionDir)
|
|
2238
|
+
configured = settings.sessionDir;
|
|
2239
|
+
} catch (error) {
|
|
2240
|
+
if (!missing(error)) throw new Error(`Unable to read Pi session directory setting: ${path}`);
|
|
2241
|
+
}
|
|
2242
|
+
}
|
|
2243
|
+
configured = env.PI_CODING_AGENT_SESSION_DIR || configured;
|
|
2244
|
+
return [
|
|
2245
|
+
.../* @__PURE__ */ new Set([
|
|
2246
|
+
join9(agentDirectory, "sessions"),
|
|
2247
|
+
...configured ? [piPath(configured, home, project)] : []
|
|
2248
|
+
])
|
|
2249
|
+
];
|
|
2250
|
+
}
|
|
2251
|
+
function piExtensionSource(directory, path, entry) {
|
|
2252
|
+
return `${marker(directory)}
|
|
2253
|
+
import { spawn } from 'node:child_process';
|
|
2254
|
+
|
|
2255
|
+
export default function teammyCollection(pi) {
|
|
2256
|
+
const collect = async (_event, ctx) => {
|
|
2257
|
+
try {
|
|
2258
|
+
const manager = ctx.sessionManager;
|
|
2259
|
+
const file = manager.getSessionFile();
|
|
2260
|
+
if (!file || manager.isPersisted?.() === false) return;
|
|
2261
|
+
const payload = JSON.stringify({
|
|
2262
|
+
cwd: manager.getCwd?.() ?? ctx.cwd,
|
|
2263
|
+
session_id: manager.getSessionId(),
|
|
2264
|
+
transcript_path: file,
|
|
2265
|
+
});
|
|
2266
|
+
const child = spawn(${JSON.stringify(process.execPath)}, [
|
|
2267
|
+
${JSON.stringify(join9(dirname5(entry), "session-hook.js"))},
|
|
2268
|
+
'--agent', 'pi', '--state-dir', ${JSON.stringify(directory)},
|
|
2269
|
+
'--integration-file', ${JSON.stringify(path)},
|
|
2270
|
+
], { stdio: ['pipe', 'ignore', 'ignore'], windowsHide: true, shell: false });
|
|
2271
|
+
await new Promise((resolve) => {
|
|
2272
|
+
const timer = setTimeout(() => { try { child.kill('SIGKILL'); } catch {} finish(); }, 2000);
|
|
2273
|
+
const finish = () => { clearTimeout(timer); resolve(); };
|
|
2274
|
+
child.once('error', finish);
|
|
2275
|
+
child.once('close', finish);
|
|
2276
|
+
child.stdin.on('error', () => {});
|
|
2277
|
+
child.stdin.end(payload);
|
|
2278
|
+
});
|
|
2279
|
+
} catch { /* Collection never affects the host session. */ }
|
|
2280
|
+
};
|
|
2281
|
+
// agent_end is supported by older Pi releases too. It follows message
|
|
2282
|
+
// persistence; retries or queued turns simply produce another hint later.
|
|
2283
|
+
for (const event of ['session_start', 'agent_end', 'session_shutdown']) {
|
|
2284
|
+
try { pi.on(event, collect); } catch {}
|
|
2285
|
+
}
|
|
2286
|
+
}
|
|
2287
|
+
`;
|
|
2288
|
+
}
|
|
2289
|
+
async function installPiExtension(directory, registry, agentDirectory, entry) {
|
|
2290
|
+
const path = join9(agentDirectory, "extensions", `teammy-${rootKey(directory)}.js`);
|
|
2291
|
+
await mkdir4(dirname5(path), { recursive: true });
|
|
2292
|
+
let original = null;
|
|
2293
|
+
try {
|
|
2294
|
+
original = await readStable(dirname5(path), basename2(path));
|
|
2295
|
+
} catch (error) {
|
|
2296
|
+
if (!missing(error)) throw error;
|
|
2297
|
+
}
|
|
2298
|
+
if (original && !original.bytes.toString("utf8").startsWith(marker(directory) + "\n"))
|
|
2299
|
+
throw new Error(`Pi extension is not owned by Teammy: ${path}`);
|
|
2300
|
+
if (!registry.configs.some((config) => config.path === path && config.kind === "pi-extension"))
|
|
2301
|
+
registry.configs.push({ path, agent: "pi", kind: "pi-extension", created: !original });
|
|
2302
|
+
saveRegistration(directory, registry);
|
|
2303
|
+
const bytes = Buffer.from(piExtensionSource(directory, path, entry));
|
|
2304
|
+
if (!original?.bytes.equals(bytes))
|
|
2305
|
+
await replaceFile(dirname5(path), basename2(path), bytes, original?.signature ?? null);
|
|
2306
|
+
}
|
|
2307
|
+
async function removePiExtension(directory, path) {
|
|
2308
|
+
const issues = [];
|
|
2309
|
+
const remaining = registrations(directory, (file) => issues.push(file));
|
|
2310
|
+
if (issues.length || remaining.some((r) => r.enabled && r.configs.some((c) => c.path === path)))
|
|
2311
|
+
return false;
|
|
2312
|
+
try {
|
|
2313
|
+
const original = await readStable(dirname5(path), basename2(path));
|
|
2314
|
+
if (!original.bytes.toString("utf8").startsWith(marker(directory) + "\n"))
|
|
2315
|
+
throw new Error(`Pi extension is not owned by Teammy: ${path}`);
|
|
2316
|
+
await removeFile(dirname5(path), basename2(path), original.signature);
|
|
2317
|
+
return true;
|
|
2318
|
+
} catch (error) {
|
|
2319
|
+
if (missing(error)) return false;
|
|
2320
|
+
throw error;
|
|
2321
|
+
}
|
|
2322
|
+
}
|
|
2323
|
+
|
|
1898
2324
|
// apps/client/sessions/setup.ts
|
|
1899
2325
|
var exec2 = promisify2(execFile2);
|
|
1900
2326
|
var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
@@ -1907,200 +2333,423 @@ async function exists(path, mode = constants2.F_OK) {
|
|
|
1907
2333
|
}
|
|
1908
2334
|
}
|
|
1909
2335
|
async function setupSessions(state, root, directory, options = {}) {
|
|
1910
|
-
|
|
1911
|
-
|
|
1912
|
-
const
|
|
1913
|
-
{
|
|
1914
|
-
agent: "claude-code",
|
|
1915
|
-
executables: ["claude"],
|
|
1916
|
-
storage: env.CLAUDE_CONFIG_DIR ?? join5(home, ".claude"),
|
|
1917
|
-
directories: ["projects"],
|
|
1918
|
-
config: ".claude/settings.local.json",
|
|
1919
|
-
events: ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"]
|
|
1920
|
-
},
|
|
1921
|
-
{
|
|
1922
|
-
agent: "codex",
|
|
1923
|
-
executables: ["codex"],
|
|
1924
|
-
storage: env.CODEX_HOME ?? join5(home, ".codex"),
|
|
1925
|
-
directories: ["sessions"],
|
|
1926
|
-
config: ".codex/hooks.json",
|
|
1927
|
-
events: ["SessionStart", "UserPromptSubmit", "Stop", "SessionEnd"]
|
|
1928
|
-
},
|
|
1929
|
-
{
|
|
1930
|
-
agent: "cursor",
|
|
1931
|
-
executables: ["agent", "cursor-agent"],
|
|
1932
|
-
storage: env.CURSOR_CONFIG_DIR ?? join5(home, ".cursor"),
|
|
1933
|
-
directories: ["chats", "projects"],
|
|
1934
|
-
config: ".cursor/hooks.json",
|
|
1935
|
-
events: ["sessionStart", "postToolUse", "stop", "sessionEnd"]
|
|
1936
|
-
},
|
|
1937
|
-
{
|
|
1938
|
-
agent: "pi",
|
|
1939
|
-
executables: ["pi"],
|
|
1940
|
-
storage: env.PI_CODING_AGENT_DIR ?? join5(home, ".pi", "agent"),
|
|
1941
|
-
directories: ["sessions"]
|
|
1942
|
-
}
|
|
1943
|
-
];
|
|
1944
|
-
const issues = [], configured = [];
|
|
1945
|
-
let gitRoot = project;
|
|
2336
|
+
await realpath4(root);
|
|
2337
|
+
if (!state.binding(root)) throw new Error("Connect this root before installing hooks.");
|
|
2338
|
+
const lock = acquireRootLock("hooks", join10(directory, "runtime"));
|
|
1946
2339
|
try {
|
|
1947
|
-
|
|
1948
|
-
|
|
1949
|
-
|
|
1950
|
-
|
|
1951
|
-
|
|
1952
|
-
|
|
1953
|
-
|
|
1954
|
-
|
|
1955
|
-
|
|
1956
|
-
|
|
1957
|
-
|
|
1958
|
-
|
|
1959
|
-
|
|
1960
|
-
|
|
1961
|
-
|
|
1962
|
-
|
|
1963
|
-
|
|
1964
|
-
|
|
1965
|
-
|
|
1966
|
-
|
|
1967
|
-
|
|
1968
|
-
|
|
1969
|
-
|
|
1970
|
-
|
|
1971
|
-
|
|
2340
|
+
const prior = readRegistration(directory, root);
|
|
2341
|
+
if (prior && !prior.enabled && !options.enable)
|
|
2342
|
+
return { configured: [], issues: [] };
|
|
2343
|
+
const registry = prior ?? {
|
|
2344
|
+
format: 1,
|
|
2345
|
+
root,
|
|
2346
|
+
projectDir: dirname6(root),
|
|
2347
|
+
enabled: true,
|
|
2348
|
+
configs: []
|
|
2349
|
+
};
|
|
2350
|
+
registry.enabled = true;
|
|
2351
|
+
saveRegistration(directory, registry);
|
|
2352
|
+
const home = options.home ?? homedir(), env = options.env ?? process.env;
|
|
2353
|
+
const project = dirname6(root), sessions = new SessionState(state, root);
|
|
2354
|
+
const defaults = [
|
|
2355
|
+
{
|
|
2356
|
+
agent: "claude-code",
|
|
2357
|
+
executables: ["claude"],
|
|
2358
|
+
storage: env.CLAUDE_CONFIG_DIR ?? join10(home, ".claude"),
|
|
2359
|
+
directories: ["projects"],
|
|
2360
|
+
config: ".claude/settings.local.json",
|
|
2361
|
+
events: ["Stop", "SessionEnd"]
|
|
2362
|
+
},
|
|
2363
|
+
{
|
|
2364
|
+
agent: "codex",
|
|
2365
|
+
executables: ["codex"],
|
|
2366
|
+
storage: env.CODEX_HOME ?? join10(home, ".codex"),
|
|
2367
|
+
directories: ["sessions"],
|
|
2368
|
+
config: ".codex/hooks.json",
|
|
2369
|
+
events: ["Stop", "SessionEnd"]
|
|
2370
|
+
},
|
|
2371
|
+
{
|
|
2372
|
+
agent: "claude-code",
|
|
2373
|
+
instance: "tclaude",
|
|
2374
|
+
executables: ["tclaude"],
|
|
2375
|
+
storage: join10(home, ".tclaude"),
|
|
2376
|
+
directories: ["projects"],
|
|
2377
|
+
config: ".claude/settings.local.json",
|
|
2378
|
+
events: ["Stop", "SessionEnd"]
|
|
2379
|
+
},
|
|
2380
|
+
{
|
|
2381
|
+
agent: "codex",
|
|
2382
|
+
instance: "tcodex",
|
|
2383
|
+
executables: ["tcodex"],
|
|
2384
|
+
storage: join10(home, ".tcodex"),
|
|
2385
|
+
directories: ["sessions"],
|
|
2386
|
+
config: ".codex/hooks.json",
|
|
2387
|
+
events: ["Stop", "SessionEnd"]
|
|
2388
|
+
},
|
|
2389
|
+
{
|
|
2390
|
+
agent: "cursor",
|
|
2391
|
+
executables: ["agent", "cursor-agent"],
|
|
2392
|
+
storage: env.CURSOR_CONFIG_DIR ?? join10(home, ".cursor"),
|
|
2393
|
+
directories: ["chats", "projects"],
|
|
2394
|
+
config: ".cursor/hooks.json",
|
|
2395
|
+
events: ["sessionStart", "postToolUse", "stop", "sessionEnd"]
|
|
2396
|
+
},
|
|
2397
|
+
{
|
|
2398
|
+
agent: "pi",
|
|
2399
|
+
executables: ["pi"],
|
|
2400
|
+
storage: piPath(env.PI_CODING_AGENT_DIR ?? join10(home, ".pi", "agent"), home, project),
|
|
2401
|
+
directories: []
|
|
1972
2402
|
}
|
|
1973
|
-
|
|
1974
|
-
|
|
1975
|
-
|
|
1976
|
-
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
1980
|
-
|
|
1981
|
-
|
|
1982
|
-
|
|
1983
|
-
"
|
|
1984
|
-
|
|
1985
|
-
|
|
1986
|
-
|
|
1987
|
-
|
|
1988
|
-
|
|
1989
|
-
|
|
1990
|
-
|
|
1991
|
-
|
|
1992
|
-
|
|
1993
|
-
].map(quote).join(" ") + ` # ${marker}`;
|
|
1994
|
-
for (const event of adapter.events) {
|
|
1995
|
-
const entries = hooks[event] ?? [];
|
|
1996
|
-
if (!Array.isArray(entries)) throw new Error(`Invalid ${event} hooks.`);
|
|
1997
|
-
const retained = entries.flatMap((entry) => {
|
|
1998
|
-
const value = object(entry);
|
|
1999
|
-
if (adapter.agent === "cursor")
|
|
2000
|
-
return typeof value.command === "string" && value.command.includes(marker) ? [] : [entry];
|
|
2001
|
-
if (!Array.isArray(value.hooks)) return [entry];
|
|
2002
|
-
const nested = value.hooks.filter(
|
|
2003
|
-
(hook) => !(typeof object(hook).command === "string" && String(object(hook).command).includes(marker))
|
|
2004
|
-
);
|
|
2005
|
-
return nested.length ? [{ ...value, hooks: nested }] : [];
|
|
2006
|
-
});
|
|
2007
|
-
hooks[event] = [
|
|
2008
|
-
...retained,
|
|
2009
|
-
adapter.agent === "cursor" ? { command, timeout: 3 } : { hooks: [{ type: "command", command, timeout: 3 }] }
|
|
2010
|
-
];
|
|
2403
|
+
];
|
|
2404
|
+
const issues = [], configured = [];
|
|
2405
|
+
let gitRoot = project;
|
|
2406
|
+
try {
|
|
2407
|
+
gitRoot = (await exec2("git", ["-C", project, "rev-parse", "--show-toplevel"])).stdout.trim();
|
|
2408
|
+
} catch {
|
|
2409
|
+
}
|
|
2410
|
+
const written = /* @__PURE__ */ new Set();
|
|
2411
|
+
for (const adapter of defaults) {
|
|
2412
|
+
const installed = options.agents ? options.agents.includes(adapter.instance ?? adapter.agent) : await exists(adapter.storage) || (await Promise.all(
|
|
2413
|
+
(env.PATH ?? "").split(":").flatMap(
|
|
2414
|
+
(dir) => adapter.executables.map((name2) => exists(join10(dir, name2), constants2.X_OK))
|
|
2415
|
+
)
|
|
2416
|
+
)).some(Boolean);
|
|
2417
|
+
const piProfiles = adapter.agent === "pi" ? registry.configs.filter((config) => config.kind === "pi-extension").map((config) => dirname6(dirname6(config.path))) : [];
|
|
2418
|
+
if (!installed && !piProfiles.length) continue;
|
|
2419
|
+
for (const sub of adapter.directories) {
|
|
2420
|
+
const source = resolve4(adapter.storage, sub);
|
|
2421
|
+
if (!registry.excludedDiscovery?.includes(source))
|
|
2422
|
+
sessions.discover(adapter.agent, source, project);
|
|
2011
2423
|
}
|
|
2012
|
-
|
|
2013
|
-
|
|
2014
|
-
|
|
2015
|
-
if (
|
|
2016
|
-
|
|
2017
|
-
|
|
2018
|
-
|
|
2019
|
-
|
|
2020
|
-
|
|
2021
|
-
|
|
2022
|
-
|
|
2424
|
+
if (adapter.agent === "cursor" && !registry.excludedDiscovery?.includes(join10(home, ".cursor", "projects")))
|
|
2425
|
+
sessions.discover("cursor", join10(home, ".cursor", "projects"), project);
|
|
2426
|
+
if (!configured.includes(adapter.agent)) configured.push(adapter.agent);
|
|
2427
|
+
if (adapter.agent === "pi") {
|
|
2428
|
+
for (const profile of /* @__PURE__ */ new Set([...piProfiles, ...installed ? [adapter.storage] : []])) {
|
|
2429
|
+
try {
|
|
2430
|
+
if (profile !== adapter.storage && !await exists(profile))
|
|
2431
|
+
throw new Error(`Registered Pi profile is missing: ${profile}`);
|
|
2432
|
+
await installPiExtension(
|
|
2433
|
+
directory,
|
|
2434
|
+
registry,
|
|
2435
|
+
profile,
|
|
2436
|
+
options.entry ?? resolve4(process.argv[1])
|
|
2437
|
+
);
|
|
2438
|
+
for (const source of await piDiscoveryDirectories(profile, project, home, env)) {
|
|
2439
|
+
if (!registry.excludedDiscovery?.includes(source))
|
|
2440
|
+
sessions.discover("pi", source, project);
|
|
2441
|
+
}
|
|
2442
|
+
} catch (error) {
|
|
2443
|
+
issues.push(
|
|
2444
|
+
`Pi: ${error instanceof Error ? error.message : "Extension setup failed."}`
|
|
2445
|
+
);
|
|
2446
|
+
}
|
|
2447
|
+
}
|
|
2023
2448
|
}
|
|
2024
|
-
|
|
2449
|
+
if (!adapter.config) continue;
|
|
2450
|
+
const configRoot = adapter.agent === "cursor" ? gitRoot : project;
|
|
2451
|
+
const path = join10(configRoot, adapter.config);
|
|
2452
|
+
if (written.has(path)) continue;
|
|
2453
|
+
written.add(path);
|
|
2025
2454
|
try {
|
|
2026
|
-
|
|
2027
|
-
|
|
2028
|
-
|
|
2029
|
-
|
|
2030
|
-
|
|
2031
|
-
|
|
2032
|
-
|
|
2033
|
-
|
|
2034
|
-
|
|
2035
|
-
|
|
2036
|
-
|
|
2037
|
-
|
|
2038
|
-
|
|
2039
|
-
|
|
2455
|
+
await mkdir5(dirname6(path), { recursive: true });
|
|
2456
|
+
let original = null;
|
|
2457
|
+
try {
|
|
2458
|
+
original = await readStable(configRoot, adapter.config);
|
|
2459
|
+
} catch (error) {
|
|
2460
|
+
if (!missing(error)) throw error;
|
|
2461
|
+
}
|
|
2462
|
+
const config = original ? JSON.parse(original.bytes.toString("utf8")) : {};
|
|
2463
|
+
if (!config || typeof config !== "object" || Array.isArray(config))
|
|
2464
|
+
throw new Error("Hook configuration must be a JSON object.");
|
|
2465
|
+
if (config.hooks !== void 0 && (!config.hooks || typeof config.hooks !== "object" || Array.isArray(config.hooks)))
|
|
2466
|
+
throw new Error("Invalid existing hooks object.");
|
|
2467
|
+
const hooks = object(config.hooks);
|
|
2468
|
+
const marker2 = hookMarker(directory, root);
|
|
2469
|
+
const cliEntry = options.entry ?? resolve4(process.argv[1]);
|
|
2470
|
+
const hookEntry = join10(dirname6(cliEntry), "session-hook.js");
|
|
2471
|
+
const invocation = [
|
|
2472
|
+
process.execPath,
|
|
2473
|
+
hookEntry,
|
|
2474
|
+
"--agent",
|
|
2475
|
+
adapter.agent,
|
|
2476
|
+
"--root",
|
|
2477
|
+
root,
|
|
2478
|
+
"--state-dir",
|
|
2479
|
+
directory,
|
|
2480
|
+
"--project-dir",
|
|
2481
|
+
project
|
|
2482
|
+
].map(quote).join(" ");
|
|
2483
|
+
const command = `( ${invocation} ) >/dev/null 2>&1 || : # ${marker2}`;
|
|
2484
|
+
removeOwned(hooks, adapter.agent, directory, root);
|
|
2485
|
+
for (const event of adapter.events) {
|
|
2486
|
+
const entries = hooks[event] ?? [];
|
|
2487
|
+
if (!Array.isArray(entries)) throw new Error(`Invalid ${event} hooks.`);
|
|
2488
|
+
hooks[event] = [
|
|
2489
|
+
...entries,
|
|
2490
|
+
adapter.agent === "cursor" ? { command, timeout: 3 } : { hooks: [{ type: "command", command, timeout: 3 }] }
|
|
2491
|
+
];
|
|
2492
|
+
}
|
|
2493
|
+
let owned = registry.configs.find((item) => item.path === path);
|
|
2494
|
+
if (!owned) {
|
|
2495
|
+
owned = { path, agent: adapter.agent, created: !original };
|
|
2496
|
+
registry.configs.push(owned);
|
|
2497
|
+
}
|
|
2498
|
+
saveRegistration(directory, registry);
|
|
2499
|
+
config.hooks = hooks;
|
|
2500
|
+
if (adapter.agent === "cursor") config.version ??= 1;
|
|
2501
|
+
const bytes = Buffer.from(JSON.stringify(config, null, 2) + "\n");
|
|
2502
|
+
if (original?.bytes.equals(bytes)) continue;
|
|
2503
|
+
if (original) {
|
|
2504
|
+
const backupDir = join10(directory, "hook-backups", rootKey(root));
|
|
2505
|
+
await mkdir5(backupDir, { recursive: true, mode: 448 });
|
|
2506
|
+
const hash2 = createHash6("sha256").update(original.bytes).digest("hex");
|
|
2507
|
+
await writeFile(join10(backupDir, `${adapter.agent}-${hash2}.json`), original.bytes, {
|
|
2508
|
+
mode: 384
|
|
2040
2509
|
});
|
|
2041
|
-
|
|
2042
|
-
|
|
2043
|
-
|
|
2044
|
-
|
|
2510
|
+
}
|
|
2511
|
+
await replaceFile(configRoot, adapter.config, bytes, original?.signature ?? null);
|
|
2512
|
+
try {
|
|
2513
|
+
const relativePath = path.slice(gitRoot.length + 1);
|
|
2514
|
+
const tracked = (await exec2("git", ["-C", gitRoot, "ls-files", "--", relativePath])).stdout;
|
|
2515
|
+
if (!tracked) {
|
|
2516
|
+
const exclude = (await exec2("git", [
|
|
2517
|
+
"-C",
|
|
2518
|
+
gitRoot,
|
|
2519
|
+
"rev-parse",
|
|
2520
|
+
"--path-format=absolute",
|
|
2521
|
+
"--git-path",
|
|
2522
|
+
"info/exclude"
|
|
2523
|
+
])).stdout.trim();
|
|
2524
|
+
const old = await readFile2(exclude, "utf8").catch((e) => {
|
|
2525
|
+
if (missing(e)) return "";
|
|
2526
|
+
throw e;
|
|
2527
|
+
});
|
|
2528
|
+
const rule = "/" + relativePath.replace(/[\\*?[\]#! ]/g, "\\$&");
|
|
2529
|
+
if (!old.split(/\r?\n/).includes(rule)) {
|
|
2530
|
+
await mkdir5(dirname6(exclude), { recursive: true });
|
|
2531
|
+
owned.exclude = {
|
|
2532
|
+
path: exclude,
|
|
2533
|
+
line: "# teammy-hook-" + rootKey(directory) + "-" + rootKey(root) + "\n" + rule
|
|
2534
|
+
};
|
|
2535
|
+
saveRegistration(directory, registry);
|
|
2536
|
+
await writeFile(
|
|
2537
|
+
exclude,
|
|
2538
|
+
old + (old && !old.endsWith("\n") ? "\n" : "") + owned.exclude.line + "\n"
|
|
2539
|
+
);
|
|
2540
|
+
}
|
|
2045
2541
|
}
|
|
2542
|
+
} catch {
|
|
2046
2543
|
}
|
|
2047
|
-
} catch {
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
|
|
2048
2546
|
}
|
|
2049
|
-
} catch (error) {
|
|
2050
|
-
issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
|
|
2051
2547
|
}
|
|
2548
|
+
return { configured, issues };
|
|
2549
|
+
} finally {
|
|
2550
|
+
lock.close();
|
|
2052
2551
|
}
|
|
2053
|
-
return { configured, issues };
|
|
2054
2552
|
}
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
import { createHash as createHash6, randomUUID as randomUUID4 } from "node:crypto";
|
|
2058
|
-
import { mkdir as mkdir5, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
2059
|
-
import { isAbsolute as isAbsolute3, join as join6 } from "node:path";
|
|
2060
|
-
import { z as z10 } from "zod";
|
|
2061
|
-
var HintSchema = z10.object({
|
|
2062
|
-
agent: AgentSchema,
|
|
2063
|
-
path: z10.string().min(1).max(4096).refine(isAbsolute3).optional(),
|
|
2064
|
-
nativeId: z10.string().min(1).max(512).optional(),
|
|
2065
|
-
projectDir: z10.string().min(1).max(4096).refine(isAbsolute3)
|
|
2066
|
-
});
|
|
2067
|
-
function inbox(directory, root) {
|
|
2068
|
-
return join6(directory, "session-hints", createHash6("sha256").update(root).digest("hex"));
|
|
2553
|
+
function hookMarker(directory, root) {
|
|
2554
|
+
return `teammy-session-${rootKey(directory)}-${rootKey(root)}`;
|
|
2069
2555
|
}
|
|
2070
|
-
|
|
2071
|
-
const
|
|
2072
|
-
const targetDir = inbox(directory, root);
|
|
2073
|
-
await mkdir5(targetDir, { recursive: true, mode: 448 });
|
|
2074
|
-
const target = join6(
|
|
2075
|
-
targetDir,
|
|
2076
|
-
`${createHash6("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
2077
|
-
);
|
|
2078
|
-
const temporary = `${target}.${randomUUID4()}.tmp`;
|
|
2079
|
-
const handle = await open2(temporary, "wx", 384);
|
|
2556
|
+
function setDiscoveryPreference(directory, root, source, enabled) {
|
|
2557
|
+
const lock = acquireRootLock("hooks", join10(directory, "runtime"));
|
|
2080
2558
|
try {
|
|
2081
|
-
|
|
2559
|
+
const registry = readRegistration(directory, root);
|
|
2560
|
+
if (!registry) return;
|
|
2561
|
+
const excluded = new Set(registry.excludedDiscovery ?? []);
|
|
2562
|
+
if (enabled) excluded.delete(source);
|
|
2563
|
+
else excluded.add(source);
|
|
2564
|
+
registry.excludedDiscovery = [...excluded];
|
|
2565
|
+
saveRegistration(directory, registry);
|
|
2082
2566
|
} finally {
|
|
2083
|
-
|
|
2567
|
+
lock.close();
|
|
2084
2568
|
}
|
|
2085
|
-
await rename2(temporary, target);
|
|
2086
2569
|
}
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2570
|
+
function owns(command, directory, root) {
|
|
2571
|
+
if (typeof command !== "string") return false;
|
|
2572
|
+
if (command.trimEnd().endsWith("# " + hookMarker(directory, root))) return true;
|
|
2573
|
+
return command.trimEnd().endsWith("# teammy-session-" + rootKey(root)) && command.includes(quote("--state-dir") + " " + quote(directory));
|
|
2574
|
+
}
|
|
2575
|
+
function removeOwned(hooks, agent, directory, root) {
|
|
2576
|
+
let changed = false;
|
|
2577
|
+
for (const [event, entries] of Object.entries(hooks)) {
|
|
2578
|
+
if (!Array.isArray(entries)) continue;
|
|
2579
|
+
const retained = entries.flatMap((entry) => {
|
|
2580
|
+
const value = object(entry);
|
|
2581
|
+
if (agent === "cursor") return owns(value.command, directory, root) ? [] : [entry];
|
|
2582
|
+
if (!Array.isArray(value.hooks)) return [entry];
|
|
2583
|
+
const nested = value.hooks.filter((hook) => !owns(object(hook).command, directory, root));
|
|
2584
|
+
if (nested.length === value.hooks.length) return [entry];
|
|
2585
|
+
return nested.length ? [{ ...value, hooks: nested }] : [];
|
|
2586
|
+
});
|
|
2587
|
+
if (JSON.stringify(retained) === JSON.stringify(entries)) continue;
|
|
2588
|
+
changed = true;
|
|
2589
|
+
if (retained.length) hooks[event] = retained;
|
|
2590
|
+
else delete hooks[event];
|
|
2591
|
+
}
|
|
2592
|
+
return changed;
|
|
2593
|
+
}
|
|
2594
|
+
async function legacyRegistrations(directory, explicitRoot) {
|
|
2595
|
+
const roots = new Set(explicitRoot ? [explicitRoot] : []);
|
|
2596
|
+
let db;
|
|
2090
2597
|
try {
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2598
|
+
db = new DatabaseSync4(join10(directory, "client.sqlite3"), { readOnly: true });
|
|
2599
|
+
for (const row of db.prepare("SELECT root FROM connections WHERE binding_json IS NOT NULL").all())
|
|
2600
|
+
if (!explicitRoot || row.root === explicitRoot) roots.add(String(row.root));
|
|
2601
|
+
} catch {
|
|
2602
|
+
} finally {
|
|
2603
|
+
db?.close();
|
|
2095
2604
|
}
|
|
2096
|
-
|
|
2097
|
-
for await (const entry of dir) {
|
|
2098
|
-
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
2099
|
-
if (count++ >= 50) break;
|
|
2100
|
-
const path = join6(inbox(directory, root), entry.name);
|
|
2605
|
+
for (const root of roots) {
|
|
2101
2606
|
try {
|
|
2102
|
-
|
|
2103
|
-
|
|
2607
|
+
if (readRegistration(directory, root)) continue;
|
|
2608
|
+
} catch {
|
|
2609
|
+
continue;
|
|
2610
|
+
}
|
|
2611
|
+
const project = dirname6(root);
|
|
2612
|
+
let gitRoot = project;
|
|
2613
|
+
try {
|
|
2614
|
+
gitRoot = (await exec2("git", ["-C", project, "rev-parse", "--show-toplevel"])).stdout.trim();
|
|
2615
|
+
} catch {
|
|
2616
|
+
}
|
|
2617
|
+
const configs = [
|
|
2618
|
+
{ path: join10(project, ".claude/settings.local.json"), agent: "claude-code", created: false },
|
|
2619
|
+
{ path: join10(project, ".codex/hooks.json"), agent: "codex", created: false },
|
|
2620
|
+
{ path: join10(gitRoot, ".cursor/hooks.json"), agent: "cursor", created: false }
|
|
2621
|
+
];
|
|
2622
|
+
saveRegistration(directory, { format: 1, root, projectDir: project, enabled: true, configs });
|
|
2623
|
+
}
|
|
2624
|
+
}
|
|
2625
|
+
async function teardownSessions(directory, root) {
|
|
2626
|
+
const lock = acquireRootLock("hooks", join10(directory, "runtime"));
|
|
2627
|
+
const issues = [];
|
|
2628
|
+
let removed = 0;
|
|
2629
|
+
try {
|
|
2630
|
+
await legacyRegistrations(directory, root);
|
|
2631
|
+
const selected = registrations(directory, (path, error) => {
|
|
2632
|
+
issues.push(
|
|
2633
|
+
`${path}: ${error instanceof Error ? error.message : "Invalid Hook registration."}`
|
|
2634
|
+
);
|
|
2635
|
+
}).filter((item) => !root || item.root === root);
|
|
2636
|
+
for (const registration of selected) {
|
|
2637
|
+
registration.enabled = false;
|
|
2638
|
+
try {
|
|
2639
|
+
saveRegistration(directory, registration);
|
|
2640
|
+
} catch (error) {
|
|
2641
|
+
issues.push(
|
|
2642
|
+
`${registration.root}: ${error instanceof Error ? error.message : "Hook removal failed."}`
|
|
2643
|
+
);
|
|
2644
|
+
continue;
|
|
2645
|
+
}
|
|
2646
|
+
for (const config of registration.configs) {
|
|
2647
|
+
try {
|
|
2648
|
+
if (config.kind === "pi-extension") {
|
|
2649
|
+
if (await removePiExtension(directory, config.path)) removed++;
|
|
2650
|
+
continue;
|
|
2651
|
+
}
|
|
2652
|
+
const original = await readStable(
|
|
2653
|
+
dirname6(config.path),
|
|
2654
|
+
config.path.split(/[\\/]/).at(-1)
|
|
2655
|
+
);
|
|
2656
|
+
const value = JSON.parse(original.bytes.toString("utf8"));
|
|
2657
|
+
if (!value || typeof value !== "object" || Array.isArray(value))
|
|
2658
|
+
throw new Error("Invalid hook configuration.");
|
|
2659
|
+
if (value.hooks !== void 0 && (!value.hooks || typeof value.hooks !== "object" || Array.isArray(value.hooks)))
|
|
2660
|
+
throw new Error("Invalid hooks object.");
|
|
2661
|
+
const hooks = object(value.hooks);
|
|
2662
|
+
if (!removeOwned(hooks, config.agent, directory, registration.root)) continue;
|
|
2663
|
+
if (Object.keys(hooks).length) value.hooks = hooks;
|
|
2664
|
+
else delete value.hooks;
|
|
2665
|
+
const empty = !Object.keys(value).length || config.agent === "cursor" && Object.keys(value).length === 1 && value.version === 1;
|
|
2666
|
+
if (empty && config.created)
|
|
2667
|
+
await removeFile(
|
|
2668
|
+
dirname6(config.path),
|
|
2669
|
+
config.path.split(/[\\/]/).at(-1),
|
|
2670
|
+
original.signature
|
|
2671
|
+
);
|
|
2672
|
+
else if (!original.bytes.equals(Buffer.from(JSON.stringify(value, null, 2) + "\n")))
|
|
2673
|
+
await replaceFile(
|
|
2674
|
+
dirname6(config.path),
|
|
2675
|
+
config.path.split(/[\\/]/).at(-1),
|
|
2676
|
+
Buffer.from(JSON.stringify(value, null, 2) + "\n"),
|
|
2677
|
+
original.signature
|
|
2678
|
+
);
|
|
2679
|
+
if (config.exclude && !await exists(config.path)) {
|
|
2680
|
+
const text = await readFile2(config.exclude.path, "utf8");
|
|
2681
|
+
await writeFile(config.exclude.path, text.replace(config.exclude.line + "\n", ""));
|
|
2682
|
+
}
|
|
2683
|
+
removed++;
|
|
2684
|
+
} catch (error) {
|
|
2685
|
+
if (!missing(error))
|
|
2686
|
+
issues.push(
|
|
2687
|
+
`${config.path}: ${error instanceof Error ? error.message : "Hook removal failed."}`
|
|
2688
|
+
);
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
}
|
|
2692
|
+
return { removed, issues };
|
|
2693
|
+
} finally {
|
|
2694
|
+
lock.close();
|
|
2695
|
+
}
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
// apps/client/sessions/hook-entry.ts
|
|
2699
|
+
import { parseArgs } from "node:util";
|
|
2700
|
+
import { realpath as realpath5 } from "node:fs/promises";
|
|
2701
|
+
import { dirname as dirname7, isAbsolute as isAbsolute4, relative as relative4, resolve as resolve5, sep as sep4 } from "node:path";
|
|
2702
|
+
import { homedir as homedir2 } from "node:os";
|
|
2703
|
+
import { join as join12 } from "node:path";
|
|
2704
|
+
|
|
2705
|
+
// apps/client/sessions/hooks.ts
|
|
2706
|
+
import { createHash as createHash7, randomUUID as randomUUID5 } from "node:crypto";
|
|
2707
|
+
import { mkdir as mkdir6, open as open2, opendir, rename as rename2, unlink as unlink2 } from "node:fs/promises";
|
|
2708
|
+
import { isAbsolute as isAbsolute3, join as join11 } from "node:path";
|
|
2709
|
+
import { z as z12 } from "zod";
|
|
2710
|
+
var HintSchema = z12.object({
|
|
2711
|
+
agent: AgentSchema,
|
|
2712
|
+
path: z12.string().min(1).max(4096).refine(isAbsolute3).optional(),
|
|
2713
|
+
nativeId: z12.string().min(1).max(512).optional(),
|
|
2714
|
+
projectDir: z12.string().min(1).max(4096).refine(isAbsolute3)
|
|
2715
|
+
});
|
|
2716
|
+
function inbox(directory, root) {
|
|
2717
|
+
return join11(directory, "session-hints", createHash7("sha256").update(root).digest("hex"));
|
|
2718
|
+
}
|
|
2719
|
+
async function recordHint(directory, root, input) {
|
|
2720
|
+
const hint = HintSchema.parse(input);
|
|
2721
|
+
const targetDir = inbox(directory, root);
|
|
2722
|
+
await mkdir6(targetDir, { recursive: true, mode: 448 });
|
|
2723
|
+
const target = join11(
|
|
2724
|
+
targetDir,
|
|
2725
|
+
`${createHash7("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
2726
|
+
);
|
|
2727
|
+
const temporary = `${target}.${randomUUID5()}.tmp`;
|
|
2728
|
+
const handle = await open2(temporary, "wx", 384);
|
|
2729
|
+
try {
|
|
2730
|
+
await handle.writeFile(JSON.stringify(hint));
|
|
2731
|
+
} finally {
|
|
2732
|
+
await handle.close();
|
|
2733
|
+
}
|
|
2734
|
+
await rename2(temporary, target);
|
|
2735
|
+
}
|
|
2736
|
+
async function consumeHints(directory, root, sessions, refresh) {
|
|
2737
|
+
const issues = [];
|
|
2738
|
+
let dir;
|
|
2739
|
+
try {
|
|
2740
|
+
dir = await opendir(inbox(directory, root));
|
|
2741
|
+
} catch (error) {
|
|
2742
|
+
if (error.code === "ENOENT") return issues;
|
|
2743
|
+
throw error;
|
|
2744
|
+
}
|
|
2745
|
+
let count = 0;
|
|
2746
|
+
for await (const entry of dir) {
|
|
2747
|
+
if (!entry.isFile() || !entry.name.endsWith(".json")) continue;
|
|
2748
|
+
if (count++ >= 50) break;
|
|
2749
|
+
const path = join11(inbox(directory, root), entry.name);
|
|
2750
|
+
try {
|
|
2751
|
+
const handle = await open2(path, "r");
|
|
2752
|
+
let body;
|
|
2104
2753
|
try {
|
|
2105
2754
|
if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
|
|
2106
2755
|
body = await handle.readFile("utf8");
|
|
@@ -2137,9 +2786,558 @@ async function hookPayload() {
|
|
|
2137
2786
|
}
|
|
2138
2787
|
}
|
|
2139
2788
|
|
|
2789
|
+
// apps/client/sessions/hook-entry.ts
|
|
2790
|
+
async function runHook(args) {
|
|
2791
|
+
try {
|
|
2792
|
+
const { values } = parseArgs({
|
|
2793
|
+
args,
|
|
2794
|
+
options: {
|
|
2795
|
+
agent: { type: "string" },
|
|
2796
|
+
root: { type: "string" },
|
|
2797
|
+
"state-dir": { type: "string" },
|
|
2798
|
+
"project-dir": { type: "string" },
|
|
2799
|
+
file: { type: "string" },
|
|
2800
|
+
"integration-file": { type: "string" }
|
|
2801
|
+
}
|
|
2802
|
+
});
|
|
2803
|
+
const directory = await realpath5(
|
|
2804
|
+
resolve5(values["state-dir"] ?? join12(homedir2(), ".teammy", "client"))
|
|
2805
|
+
);
|
|
2806
|
+
const payload = values.file ? {} : await hookPayload();
|
|
2807
|
+
let selectedRoot = values.root;
|
|
2808
|
+
if (values["integration-file"]) {
|
|
2809
|
+
if (values.agent !== "pi" || typeof payload.cwd !== "string") return;
|
|
2810
|
+
const cwd = await realpath5(payload.cwd);
|
|
2811
|
+
const candidates2 = registrations(directory, () => {
|
|
2812
|
+
}).filter(
|
|
2813
|
+
(item) => item.enabled && item.configs.some(
|
|
2814
|
+
(config) => config.kind === "pi-extension" && config.path === values["integration-file"]
|
|
2815
|
+
)
|
|
2816
|
+
);
|
|
2817
|
+
const matches = [];
|
|
2818
|
+
for (const item of candidates2) {
|
|
2819
|
+
try {
|
|
2820
|
+
const project = await realpath5(item.projectDir);
|
|
2821
|
+
const rel = relative4(project, cwd);
|
|
2822
|
+
if (!isAbsolute4(rel) && rel !== ".." && !rel.startsWith(".." + sep4)) matches.push(item);
|
|
2823
|
+
} catch {
|
|
2824
|
+
}
|
|
2825
|
+
}
|
|
2826
|
+
matches.sort((a, b) => b.projectDir.length - a.projectDir.length);
|
|
2827
|
+
selectedRoot = matches[0]?.root;
|
|
2828
|
+
if (!selectedRoot) return;
|
|
2829
|
+
}
|
|
2830
|
+
const root = await realpath5(resolve5(selectedRoot ?? "workspaces"));
|
|
2831
|
+
const registration = readRegistration(directory, root);
|
|
2832
|
+
if (!registration?.enabled) return;
|
|
2833
|
+
const projectDir = await realpath5(registration.projectDir);
|
|
2834
|
+
if (projectDir !== await realpath5(values["project-dir"] ?? dirname7(root))) return;
|
|
2835
|
+
if (typeof payload.cwd === "string") {
|
|
2836
|
+
const rel = relative4(projectDir, await realpath5(payload.cwd));
|
|
2837
|
+
if (isAbsolute4(rel) || rel === ".." || rel.startsWith(".." + sep4)) return;
|
|
2838
|
+
}
|
|
2839
|
+
const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
|
|
2840
|
+
const nativeId = payload.session_id ?? payload.conversation_id;
|
|
2841
|
+
if (typeof file === "string" && file || typeof nativeId === "string")
|
|
2842
|
+
await recordHint(directory, root, {
|
|
2843
|
+
agent: values.agent,
|
|
2844
|
+
...typeof file === "string" && file ? { path: resolve5(projectDir, file) } : {},
|
|
2845
|
+
...typeof nativeId === "string" ? { nativeId } : {},
|
|
2846
|
+
projectDir
|
|
2847
|
+
});
|
|
2848
|
+
} catch {
|
|
2849
|
+
}
|
|
2850
|
+
}
|
|
2851
|
+
|
|
2852
|
+
// apps/client/runtime/maintenance.ts
|
|
2853
|
+
import { mkdir as mkdir9, rename as rename4, rm as rm2, readFile as readFile4 } from "node:fs/promises";
|
|
2854
|
+
import { existsSync as existsSync4 } from "node:fs";
|
|
2855
|
+
import { join as join15, resolve as resolve7 } from "node:path";
|
|
2856
|
+
import { DatabaseSync as DatabaseSync5 } from "node:sqlite";
|
|
2857
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2858
|
+
|
|
2859
|
+
// apps/client/runtime/ipc.ts
|
|
2860
|
+
import { createConnection } from "node:net";
|
|
2861
|
+
import { mkdir as mkdir7, lstat as lstat3, chmod } from "node:fs/promises";
|
|
2862
|
+
import { join as join13 } from "node:path";
|
|
2863
|
+
import { z as z13 } from "zod";
|
|
2864
|
+
var RequestSchema = z13.object({
|
|
2865
|
+
method: z13.enum(["ping", "wake", "status", "once"]),
|
|
2866
|
+
root: z13.string().max(4096).optional()
|
|
2867
|
+
}).strict();
|
|
2868
|
+
function socketPath(directory) {
|
|
2869
|
+
return join13(
|
|
2870
|
+
"/tmp",
|
|
2871
|
+
`teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
|
|
2872
|
+
"control.sock"
|
|
2873
|
+
);
|
|
2874
|
+
}
|
|
2875
|
+
async function prepareSocketDirectory(directory) {
|
|
2876
|
+
const path = join13(socketPath(directory), "..");
|
|
2877
|
+
await mkdir7(path, { recursive: true, mode: 448 });
|
|
2878
|
+
const stat2 = await lstat3(path);
|
|
2879
|
+
if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
|
|
2880
|
+
throw new Error("Unsafe daemon socket directory.");
|
|
2881
|
+
await chmod(path, 448);
|
|
2882
|
+
}
|
|
2883
|
+
var CONTROL_PROTOCOL = 1;
|
|
2884
|
+
async function daemonRequest(directory, input, timeout = 15e3) {
|
|
2885
|
+
RequestSchema.parse(input);
|
|
2886
|
+
return new Promise((resolve16, reject) => {
|
|
2887
|
+
const socket = createConnection(socketPath(directory));
|
|
2888
|
+
socket.setEncoding("utf8");
|
|
2889
|
+
let body = "";
|
|
2890
|
+
let done = false;
|
|
2891
|
+
const finish = (error, result2) => {
|
|
2892
|
+
if (done) return;
|
|
2893
|
+
done = true;
|
|
2894
|
+
socket.destroy();
|
|
2895
|
+
if (error) reject(error);
|
|
2896
|
+
else resolve16(result2);
|
|
2897
|
+
};
|
|
2898
|
+
socket.setTimeout(
|
|
2899
|
+
timeout,
|
|
2900
|
+
() => finish(
|
|
2901
|
+
Object.assign(new Error("Background service did not respond. Run teammy repair."), {
|
|
2902
|
+
code: "ETIMEDOUT"
|
|
2903
|
+
})
|
|
2904
|
+
)
|
|
2905
|
+
);
|
|
2906
|
+
socket.on("error", (e) => finish(e));
|
|
2907
|
+
socket.on("end", () => {
|
|
2908
|
+
if (!done)
|
|
2909
|
+
finish(
|
|
2910
|
+
Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
|
|
2911
|
+
);
|
|
2912
|
+
});
|
|
2913
|
+
socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
|
|
2914
|
+
socket.on("data", (chunk) => {
|
|
2915
|
+
body += chunk.toString();
|
|
2916
|
+
if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
|
|
2917
|
+
if (!body.includes("\n")) return;
|
|
2918
|
+
try {
|
|
2919
|
+
const response = JSON.parse(body.split("\n")[0]);
|
|
2920
|
+
if (response.ok) finish(void 0, response.result);
|
|
2921
|
+
else finish(new Error(response.error ?? "Background operation failed."));
|
|
2922
|
+
} catch (e) {
|
|
2923
|
+
finish(e);
|
|
2924
|
+
}
|
|
2925
|
+
});
|
|
2926
|
+
});
|
|
2927
|
+
}
|
|
2928
|
+
async function probeDaemon(directory) {
|
|
2929
|
+
try {
|
|
2930
|
+
const info = await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2931
|
+
if (!info || !Number.isSafeInteger(info.pid) || info.pid <= 0 || typeof info.version !== "string" || info.protocol !== void 0 && info.protocol !== CONTROL_PROTOCOL)
|
|
2932
|
+
return { state: "incompatible", error: "Unsupported background control protocol." };
|
|
2933
|
+
return { state: "ready", info };
|
|
2934
|
+
} catch (error) {
|
|
2935
|
+
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2936
|
+
String(error.code)
|
|
2937
|
+
))
|
|
2938
|
+
return { state: "absent" };
|
|
2939
|
+
return {
|
|
2940
|
+
state: error.code === "ETIMEDOUT" ? "unresponsive" : "incompatible",
|
|
2941
|
+
error: error instanceof Error ? error.message : "Invalid background response."
|
|
2942
|
+
};
|
|
2943
|
+
}
|
|
2944
|
+
}
|
|
2945
|
+
async function runningDaemon(directory) {
|
|
2946
|
+
try {
|
|
2947
|
+
return await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2948
|
+
} catch (error) {
|
|
2949
|
+
if (error instanceof Error && error.message === "Background service is stopping.") return null;
|
|
2950
|
+
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2951
|
+
String(error.code)
|
|
2952
|
+
))
|
|
2953
|
+
return null;
|
|
2954
|
+
throw error;
|
|
2955
|
+
}
|
|
2956
|
+
}
|
|
2957
|
+
|
|
2958
|
+
// apps/client/runtime/service.ts
|
|
2959
|
+
import { execFile as execFile3 } from "node:child_process";
|
|
2960
|
+
import { promisify as promisify3 } from "node:util";
|
|
2961
|
+
import { homedir as homedir3 } from "node:os";
|
|
2962
|
+
import { dirname as dirname8, join as join14, resolve as resolve6 } from "node:path";
|
|
2963
|
+
import { mkdir as mkdir8, readFile as readFile3, writeFile as writeFile2, rename as rename3, rm } from "node:fs/promises";
|
|
2964
|
+
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2965
|
+
var execute = promisify3(execFile3);
|
|
2966
|
+
var exec3 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
2967
|
+
var xml = (s) => s.replace(
|
|
2968
|
+
/[<>&"']/g,
|
|
2969
|
+
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
2970
|
+
);
|
|
2971
|
+
var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
|
|
2972
|
+
function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir3()) {
|
|
2973
|
+
for (const value of [directory, executable, entry])
|
|
2974
|
+
if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
|
|
2975
|
+
const id = `com.teammy.client.${rootKey(directory)}`;
|
|
2976
|
+
const args = [executable, entry, "_daemon", "--state-dir", directory];
|
|
2977
|
+
if (platform === "darwin")
|
|
2978
|
+
return {
|
|
2979
|
+
id,
|
|
2980
|
+
path: join14(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2981
|
+
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2982
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2983
|
+
<plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
|
|
2984
|
+
`
|
|
2985
|
+
};
|
|
2986
|
+
if (platform === "linux")
|
|
2987
|
+
return {
|
|
2988
|
+
id: id + ".service",
|
|
2989
|
+
path: join14(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2990
|
+
content: `[Unit]
|
|
2991
|
+
Description=Teammy background synchronization
|
|
2992
|
+
[Service]
|
|
2993
|
+
Type=simple
|
|
2994
|
+
ExecStart=${args.map(unit).join(" ")}
|
|
2995
|
+
Restart=always
|
|
2996
|
+
RestartSec=30
|
|
2997
|
+
UMask=0077
|
|
2998
|
+
StandardOutput=null
|
|
2999
|
+
StandardError=journal
|
|
3000
|
+
[Install]
|
|
3001
|
+
WantedBy=default.target
|
|
3002
|
+
`
|
|
3003
|
+
};
|
|
3004
|
+
throw new Error(
|
|
3005
|
+
"Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
|
|
3006
|
+
);
|
|
3007
|
+
}
|
|
3008
|
+
async function checkServiceSupport() {
|
|
3009
|
+
try {
|
|
3010
|
+
if (process.platform === "darwin")
|
|
3011
|
+
await exec3("launchctl", ["print", `gui/${process.getuid()}`], {
|
|
3012
|
+
maxBuffer: 4 * 1024 * 1024
|
|
3013
|
+
});
|
|
3014
|
+
else if (process.platform === "linux") await exec3("systemctl", ["--user", "show-environment"]);
|
|
3015
|
+
else throw new Error("unsupported");
|
|
3016
|
+
} catch {
|
|
3017
|
+
throw new Error(
|
|
3018
|
+
"A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
|
|
3019
|
+
);
|
|
3020
|
+
}
|
|
3021
|
+
}
|
|
3022
|
+
async function bootstrapLaunchAgent(path) {
|
|
3023
|
+
const deadline = Date.now() + 1e4;
|
|
3024
|
+
for (; ; ) {
|
|
3025
|
+
try {
|
|
3026
|
+
await exec3("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
|
|
3027
|
+
return;
|
|
3028
|
+
} catch (error) {
|
|
3029
|
+
if (error.code !== 5 || Date.now() >= deadline) throw error;
|
|
3030
|
+
await setTimeout3(250);
|
|
3031
|
+
}
|
|
3032
|
+
}
|
|
3033
|
+
}
|
|
3034
|
+
async function ensureDaemon(directory, version) {
|
|
3035
|
+
const definition = serviceDefinition(directory, process.execPath, resolve6(process.argv[1]));
|
|
3036
|
+
let old = "";
|
|
3037
|
+
try {
|
|
3038
|
+
old = await readFile3(definition.path, "utf8");
|
|
3039
|
+
} catch (e) {
|
|
3040
|
+
if (e.code !== "ENOENT") throw e;
|
|
3041
|
+
}
|
|
3042
|
+
const probe = await probeDaemon(directory);
|
|
3043
|
+
const alive = probe.state === "ready" ? probe.info : null;
|
|
3044
|
+
if (alive && old === definition.content && alive.version === version && alive.protocol === CONTROL_PROTOCOL)
|
|
3045
|
+
return;
|
|
3046
|
+
await checkServiceSupport();
|
|
3047
|
+
await mkdir8(dirname8(definition.path), { recursive: true });
|
|
3048
|
+
if (old !== definition.content) {
|
|
3049
|
+
const temp = definition.path + `.${process.pid}.tmp`;
|
|
3050
|
+
await writeFile2(temp, definition.content, { mode: 384 });
|
|
3051
|
+
await rename3(temp, definition.path);
|
|
3052
|
+
}
|
|
3053
|
+
if (process.platform === "darwin") {
|
|
3054
|
+
const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
|
|
3055
|
+
let loaded = false;
|
|
3056
|
+
try {
|
|
3057
|
+
await exec3("launchctl", ["print", target]);
|
|
3058
|
+
loaded = true;
|
|
3059
|
+
} catch {
|
|
3060
|
+
}
|
|
3061
|
+
if (loaded && (old !== definition.content || alive?.version !== version || alive?.protocol !== CONTROL_PROTOCOL)) {
|
|
3062
|
+
await exec3("launchctl", ["bootout", target]);
|
|
3063
|
+
loaded = false;
|
|
3064
|
+
}
|
|
3065
|
+
if (!loaded) await bootstrapLaunchAgent(definition.path);
|
|
3066
|
+
await exec3("launchctl", ["kickstart", target]);
|
|
3067
|
+
} else {
|
|
3068
|
+
await exec3("systemctl", ["--user", "daemon-reload"]);
|
|
3069
|
+
await exec3("systemctl", ["--user", "enable", definition.id]);
|
|
3070
|
+
await exec3("systemctl", [
|
|
3071
|
+
"--user",
|
|
3072
|
+
old !== definition.content || alive?.version !== version || alive?.protocol !== CONTROL_PROTOCOL ? "restart" : "start",
|
|
3073
|
+
definition.id
|
|
3074
|
+
]);
|
|
3075
|
+
}
|
|
3076
|
+
const deadline = Date.now() + 15e3;
|
|
3077
|
+
while (Date.now() < deadline) {
|
|
3078
|
+
const current = await probeDaemon(directory);
|
|
3079
|
+
if (current.state === "ready" && current.info.version === version && current.info.protocol === CONTROL_PROTOCOL)
|
|
3080
|
+
return;
|
|
3081
|
+
await setTimeout3(150);
|
|
3082
|
+
}
|
|
3083
|
+
throw new Error(
|
|
3084
|
+
"Background service did not start. Run teammy logs and check the user service manager."
|
|
3085
|
+
);
|
|
3086
|
+
}
|
|
3087
|
+
async function wakeDaemon(directory, version) {
|
|
3088
|
+
await ensureDaemon(directory, version);
|
|
3089
|
+
await daemonRequest(directory, { method: "wake" });
|
|
3090
|
+
}
|
|
3091
|
+
async function stopDaemon(directory, remove = false) {
|
|
3092
|
+
const definition = serviceDefinition(directory, process.execPath, resolve6(process.argv[1]));
|
|
3093
|
+
let loaded = false;
|
|
3094
|
+
let pid;
|
|
3095
|
+
try {
|
|
3096
|
+
const saved = JSON.parse(await readFile3(join14(directory, "runtime", "daemon.json"), "utf8"));
|
|
3097
|
+
if (saved.directory === directory && Number.isSafeInteger(saved.pid) && saved.pid > 0)
|
|
3098
|
+
pid = saved.pid;
|
|
3099
|
+
} catch {
|
|
3100
|
+
}
|
|
3101
|
+
const target = process.platform === "darwin" ? `gui/${process.getuid()}/${definition.id}` : definition.id;
|
|
3102
|
+
try {
|
|
3103
|
+
const output = process.platform === "darwin" ? (await exec3("launchctl", ["print", target])).stdout : (await exec3("systemctl", ["--user", "show", target, "--property=LoadState,MainPID"])).stdout;
|
|
3104
|
+
loaded = process.platform === "darwin" || !output.includes("LoadState=not-found");
|
|
3105
|
+
const match = output.match(process.platform === "darwin" ? /\bpid = (\d+)/ : /MainPID=(\d+)/);
|
|
3106
|
+
if (match && Number(match[1]) > 0) pid = Number(match[1]);
|
|
3107
|
+
} catch {
|
|
3108
|
+
}
|
|
3109
|
+
if (loaded) {
|
|
3110
|
+
try {
|
|
3111
|
+
if (process.platform === "darwin")
|
|
3112
|
+
await exec3("launchctl", ["bootout", target], { timeout: 3e4 });
|
|
3113
|
+
else await exec3("systemctl", ["--user", "stop", target], { timeout: 3e4 });
|
|
3114
|
+
} catch {
|
|
3115
|
+
if (process.platform === "darwin") {
|
|
3116
|
+
await exec3("launchctl", ["kill", "SIGKILL", target]);
|
|
3117
|
+
await exec3("launchctl", ["bootout", target], { timeout: 5e3 });
|
|
3118
|
+
} else {
|
|
3119
|
+
await exec3("systemctl", ["--user", "kill", "--signal=SIGKILL", "--kill-whom=all", target]);
|
|
3120
|
+
await exec3("systemctl", ["--user", "stop", target], { timeout: 5e3 });
|
|
3121
|
+
}
|
|
3122
|
+
}
|
|
3123
|
+
if (process.platform === "linux") await exec3("systemctl", ["--user", "disable", target]);
|
|
3124
|
+
}
|
|
3125
|
+
const probe = await probeDaemon(directory);
|
|
3126
|
+
if (probe.state === "ready") pid = probe.info.pid;
|
|
3127
|
+
if (pid) {
|
|
3128
|
+
let command = "";
|
|
3129
|
+
try {
|
|
3130
|
+
command = (await exec3("ps", ["-p", String(pid), "-o", "uid=", "-o", "command="])).stdout;
|
|
3131
|
+
} catch {
|
|
3132
|
+
}
|
|
3133
|
+
if (command.trim()) {
|
|
3134
|
+
const owned = Number(command.trim().split(/\s+/)[0]) === process.getuid?.() && command.includes("_daemon") && command.includes(directory);
|
|
3135
|
+
if (!owned) {
|
|
3136
|
+
if (probe.state !== "absent")
|
|
3137
|
+
throw new Error(
|
|
3138
|
+
"Cannot verify background process ownership; no unrelated process was stopped."
|
|
3139
|
+
);
|
|
3140
|
+
command = "";
|
|
3141
|
+
}
|
|
3142
|
+
if (owned) {
|
|
3143
|
+
try {
|
|
3144
|
+
process.kill(pid, "SIGTERM");
|
|
3145
|
+
} catch (e) {
|
|
3146
|
+
if (e.code !== "ESRCH") throw e;
|
|
3147
|
+
}
|
|
3148
|
+
const deadline2 = Date.now() + 3e4;
|
|
3149
|
+
while (Date.now() < deadline2) {
|
|
3150
|
+
try {
|
|
3151
|
+
process.kill(pid, 0);
|
|
3152
|
+
} catch {
|
|
3153
|
+
break;
|
|
3154
|
+
}
|
|
3155
|
+
await setTimeout3(100);
|
|
3156
|
+
}
|
|
3157
|
+
let current = "";
|
|
3158
|
+
try {
|
|
3159
|
+
current = (await exec3("ps", ["-p", String(pid), "-o", "uid=", "-o", "command="])).stdout;
|
|
3160
|
+
} catch {
|
|
3161
|
+
}
|
|
3162
|
+
if (current === command && current.trim()) {
|
|
3163
|
+
try {
|
|
3164
|
+
process.kill(pid, "SIGKILL");
|
|
3165
|
+
} catch (e) {
|
|
3166
|
+
if (e.code !== "ESRCH") throw e;
|
|
3167
|
+
}
|
|
3168
|
+
}
|
|
3169
|
+
}
|
|
3170
|
+
}
|
|
3171
|
+
}
|
|
3172
|
+
const deadline = Date.now() + 5e3;
|
|
3173
|
+
for (; ; ) {
|
|
3174
|
+
try {
|
|
3175
|
+
const lock = acquireRootLock("daemon:" + directory, directory);
|
|
3176
|
+
lock.close();
|
|
3177
|
+
break;
|
|
3178
|
+
} catch (error) {
|
|
3179
|
+
if (Date.now() >= deadline) throw error;
|
|
3180
|
+
await setTimeout3(100);
|
|
3181
|
+
}
|
|
3182
|
+
}
|
|
3183
|
+
const remaining = await probeDaemon(directory);
|
|
3184
|
+
if (remaining.state !== "absent")
|
|
3185
|
+
throw new Error("Background service has not stopped. Inspect the user service manager.");
|
|
3186
|
+
await rm(socketPath(directory), { force: true });
|
|
3187
|
+
if (remove || process.platform === "darwin") await rm(definition.path, { force: true });
|
|
3188
|
+
}
|
|
3189
|
+
|
|
3190
|
+
// apps/client/runtime/maintenance.ts
|
|
3191
|
+
var services = { stop: stopDaemon, start: ensureDaemon };
|
|
3192
|
+
async function prepareClient(directory, version, options = {}, control = services) {
|
|
3193
|
+
const record = options.stop || options.uninstall ? null : readMaintenance(directory);
|
|
3194
|
+
if (options.daemonStartup && record)
|
|
3195
|
+
throw new Error("Client is paused for maintenance. Run teammy repair.");
|
|
3196
|
+
const schema = options.stop || options.uninstall ? void 0 : stateVersion(directory);
|
|
3197
|
+
const installation = {
|
|
3198
|
+
version,
|
|
3199
|
+
executable: process.execPath,
|
|
3200
|
+
entry: resolve7(process.argv[1]),
|
|
3201
|
+
protocol: CONTROL_PROTOCOL,
|
|
3202
|
+
schema: clientSchemaVersion
|
|
3203
|
+
};
|
|
3204
|
+
let installed;
|
|
3205
|
+
try {
|
|
3206
|
+
installed = JSON.parse(await readFile4(join15(directory, "runtime/installation.json"), "utf8"));
|
|
3207
|
+
} catch {
|
|
3208
|
+
}
|
|
3209
|
+
const probe = options.daemonStartup ? { state: "absent" } : await probeDaemon(directory);
|
|
3210
|
+
const changed = probe.state === "ready" && (probe.info.version !== version || probe.info.protocol !== CONTROL_PROTOCOL || probe.info.executable !== process.execPath || probe.info.entry !== resolve7(process.argv[1]));
|
|
3211
|
+
const required = options.force || options.stop || options.uninstall || schema !== clientSchemaVersion || JSON.stringify(installed) !== JSON.stringify(installation) || changed || probe.state === "unresponsive" || probe.state === "incompatible" || Boolean(record && (options.resume || ["repair", "upgrade"].includes(record.reason)));
|
|
3212
|
+
if (!required) return { maintained: false, enabled: 0, stopped: 0, issues: [] };
|
|
3213
|
+
const mutex = acquireRootLock("maintenance", join15(directory, "runtime"));
|
|
3214
|
+
const log = new ClientLog(directory);
|
|
3215
|
+
let access2;
|
|
3216
|
+
let legacy;
|
|
3217
|
+
let state;
|
|
3218
|
+
let enabled = 0, stopped = 0;
|
|
3219
|
+
let restart = false;
|
|
3220
|
+
const issues = [];
|
|
3221
|
+
const pauseReason = options.resume ? void 0 : record?.reason === "paused" || record?.reason === "uninstalled" ? record.reason : record?.pauseReason;
|
|
3222
|
+
try {
|
|
3223
|
+
const reason = options.uninstall ? "uninstalled" : options.stop ? "paused" : options.force ? "repair" : "upgrade";
|
|
3224
|
+
writeMaintenance(directory, {
|
|
3225
|
+
reason,
|
|
3226
|
+
phase: "stopping",
|
|
3227
|
+
version,
|
|
3228
|
+
...pauseReason ? { pauseReason } : {}
|
|
3229
|
+
});
|
|
3230
|
+
log.write("maintenance.started", void 0, `version=${version} reason=${reason}`);
|
|
3231
|
+
if (!options.daemonStartup) await control.stop(directory, Boolean(options.uninstall));
|
|
3232
|
+
if (options.stop || options.uninstall) {
|
|
3233
|
+
if (options.uninstall) issues.push(...(await teardownSessions(directory)).issues);
|
|
3234
|
+
writeMaintenance(directory, { reason, phase: "paused", version });
|
|
3235
|
+
log.write("maintenance.paused");
|
|
3236
|
+
} else {
|
|
3237
|
+
const deadline = Date.now() + 5e3;
|
|
3238
|
+
while (!access2) {
|
|
3239
|
+
try {
|
|
3240
|
+
access2 = stateAccess(directory, true);
|
|
3241
|
+
} catch (error) {
|
|
3242
|
+
if (!(error instanceof Error) || !error.message.includes("Client state is in use") || Date.now() >= deadline)
|
|
3243
|
+
throw error;
|
|
3244
|
+
await delay(100);
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
legacy = legacyStateLocks(directory);
|
|
3248
|
+
const before = stateVersion(directory);
|
|
3249
|
+
if (before > clientSchemaVersion)
|
|
3250
|
+
throw new Error(
|
|
3251
|
+
"Database was created by a newer Teammy version. Install a compatible client; teammy repair --stop and logs remain available."
|
|
3252
|
+
);
|
|
3253
|
+
let backup;
|
|
3254
|
+
if (before > 0 && before < clientSchemaVersion) {
|
|
3255
|
+
const backups = join15(directory, "backups");
|
|
3256
|
+
await mkdir9(backups, { recursive: true, mode: 448 });
|
|
3257
|
+
backup = join15(backups, `before-schema-${before}.sqlite3`);
|
|
3258
|
+
if (!existsSync4(backup)) {
|
|
3259
|
+
const temp = backup + ".tmp";
|
|
3260
|
+
await rm2(temp, { force: true });
|
|
3261
|
+
const db = new DatabaseSync5(join15(directory, "client.sqlite3"));
|
|
3262
|
+
try {
|
|
3263
|
+
db.prepare("VACUUM INTO ?").run(temp);
|
|
3264
|
+
} finally {
|
|
3265
|
+
db.close();
|
|
3266
|
+
}
|
|
3267
|
+
const { chmod: chmod3 } = await import("node:fs/promises");
|
|
3268
|
+
await chmod3(temp, 384);
|
|
3269
|
+
await rename4(temp, backup);
|
|
3270
|
+
}
|
|
3271
|
+
log.write("maintenance.backup", void 0, backup);
|
|
3272
|
+
}
|
|
3273
|
+
writeMaintenance(directory, {
|
|
3274
|
+
reason,
|
|
3275
|
+
phase: "migrating",
|
|
3276
|
+
version,
|
|
3277
|
+
...pauseReason ? { pauseReason } : {},
|
|
3278
|
+
...backup ? { backup } : {}
|
|
3279
|
+
});
|
|
3280
|
+
openDatabase(join15(directory, "client.sqlite3"), clientMigrations).close();
|
|
3281
|
+
state = new ClientState(directory, { maintenance: true });
|
|
3282
|
+
const runtime = new RuntimeState(state);
|
|
3283
|
+
if (options.intent) {
|
|
3284
|
+
if (options.intent.enabled) await verifyWorkspace(state, options.intent.root);
|
|
3285
|
+
runtime.enable(options.intent.root, options.intent.enabled);
|
|
3286
|
+
}
|
|
3287
|
+
writeMaintenance(directory, {
|
|
3288
|
+
reason,
|
|
3289
|
+
phase: "configuring",
|
|
3290
|
+
version,
|
|
3291
|
+
...pauseReason ? { pauseReason } : {},
|
|
3292
|
+
...backup ? { backup } : {}
|
|
3293
|
+
});
|
|
3294
|
+
for (const { root } of state.db.prepare("SELECT root FROM connections WHERE binding_json IS NOT NULL").all()) {
|
|
3295
|
+
if (runtime.enabled(String(root))) enabled++;
|
|
3296
|
+
else stopped++;
|
|
3297
|
+
try {
|
|
3298
|
+
issues.push(...(await setupSessions(state, String(root), directory)).issues);
|
|
3299
|
+
} catch (error) {
|
|
3300
|
+
issues.push(`${root}: ${diagnostic(error)}`);
|
|
3301
|
+
}
|
|
3302
|
+
}
|
|
3303
|
+
writePrivateJson(join15(directory, "runtime/installation.json"), installation);
|
|
3304
|
+
if (pauseReason)
|
|
3305
|
+
writeMaintenance(directory, { reason: pauseReason, phase: "paused", version });
|
|
3306
|
+
else await rm2(maintenancePath(directory), { force: true });
|
|
3307
|
+
restart = !options.daemonStartup && !pauseReason && enabled > 0;
|
|
3308
|
+
if (backup) {
|
|
3309
|
+
const { readdir: readdir3 } = await import("node:fs/promises");
|
|
3310
|
+
for (const file of await readdir3(join15(directory, "backups"))) {
|
|
3311
|
+
if (/^before-schema-\d+\.sqlite3$/.test(file) && join15(directory, "backups", file) !== backup)
|
|
3312
|
+
await rm2(join15(directory, "backups", file));
|
|
3313
|
+
}
|
|
3314
|
+
}
|
|
3315
|
+
log.write(
|
|
3316
|
+
"maintenance.completed",
|
|
3317
|
+
void 0,
|
|
3318
|
+
`version=${version} enabled=${enabled} issues=${issues.length}`
|
|
3319
|
+
);
|
|
3320
|
+
}
|
|
3321
|
+
for (const issue of issues) log.write("sessions.setup.failed", void 0, issue);
|
|
3322
|
+
} catch (error) {
|
|
3323
|
+
log.write("maintenance.failed", void 0, diagnostic(error));
|
|
3324
|
+
throw error;
|
|
3325
|
+
} finally {
|
|
3326
|
+
state?.close();
|
|
3327
|
+
legacy?.close();
|
|
3328
|
+
access2?.close();
|
|
3329
|
+
mutex.close();
|
|
3330
|
+
}
|
|
3331
|
+
if (restart) await control.start(directory, version);
|
|
3332
|
+
return { maintained: true, enabled, stopped, issues };
|
|
3333
|
+
}
|
|
3334
|
+
|
|
2140
3335
|
// apps/client/sessions/command.ts
|
|
2141
3336
|
var help = `Usage: teammy session COMMAND [options]
|
|
2142
3337
|
|
|
3338
|
+
Collect Agent conversations into the conversation library (\u5BF9\u8BDD\u5E93), then browse or search them.
|
|
3339
|
+
|
|
3340
|
+
teardown Remove project hooks; --all removes all registered hooks (keeps discovery)
|
|
2143
3341
|
setup Configure project hooks and native discovery (also done by connect)
|
|
2144
3342
|
track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
|
|
2145
3343
|
untrack --file PATH (does not delete server history)
|
|
@@ -2157,17 +3355,21 @@ track/discover only register local sources. Run teammy sync to collect and publi
|
|
|
2157
3355
|
Discover uses native default locations except DSH, which requires --directory.
|
|
2158
3356
|
Hook only records a local hint, emits no stdout, and always exits successfully.
|
|
2159
3357
|
`;
|
|
2160
|
-
async function runSessionCommand(args) {
|
|
3358
|
+
async function runSessionCommand(args, version) {
|
|
2161
3359
|
const command = args[0];
|
|
2162
3360
|
if (!command || ["--help", "-h", "help"].includes(command)) {
|
|
2163
3361
|
process.stdout.write(help);
|
|
2164
3362
|
return 0;
|
|
2165
3363
|
}
|
|
2166
|
-
|
|
3364
|
+
if (command === "hook") {
|
|
3365
|
+
await runHook(args.slice(1));
|
|
3366
|
+
return 0;
|
|
3367
|
+
}
|
|
3368
|
+
const { values } = parseArgs2({
|
|
2167
3369
|
args: args.slice(1),
|
|
2168
3370
|
options: {
|
|
2169
|
-
root: { type: "string", default:
|
|
2170
|
-
"state-dir": { type: "string", default:
|
|
3371
|
+
root: { type: "string", default: resolve8("workspaces") },
|
|
3372
|
+
"state-dir": { type: "string", default: join16(homedir4(), ".teammy", "client") },
|
|
2171
3373
|
agent: { type: "string" },
|
|
2172
3374
|
file: { type: "string" },
|
|
2173
3375
|
directory: { type: "string" },
|
|
@@ -2179,29 +3381,25 @@ async function runSessionCommand(args) {
|
|
|
2179
3381
|
query: { type: "string" },
|
|
2180
3382
|
after: { type: "string", default: "0" },
|
|
2181
3383
|
limit: { type: "string", default: "50" },
|
|
2182
|
-
help: { type: "boolean", short: "h" }
|
|
3384
|
+
help: { type: "boolean", short: "h" },
|
|
3385
|
+
all: { type: "boolean" }
|
|
2183
3386
|
}
|
|
2184
|
-
});
|
|
2185
|
-
if (values.help) {
|
|
2186
|
-
process.stdout.write(help);
|
|
2187
|
-
return 0;
|
|
2188
|
-
}
|
|
2189
|
-
const root = await realpath4(resolve4(values.root));
|
|
2190
|
-
const directory = await stateLocation(root, values["state-dir"]);
|
|
2191
|
-
const projectDir = await realpath4(resolve4(values["project-dir"] ?? dirname5(root)));
|
|
2192
|
-
if (command === "hook") {
|
|
2193
|
-
const payload = values.file ? {} : await hookPayload();
|
|
2194
|
-
const file = values.file ?? payload.transcript_path ?? payload.session_file ?? payload.sessionFile;
|
|
2195
|
-
const nativeId = payload.session_id ?? payload.conversation_id;
|
|
2196
|
-
if (typeof file === "string" && file || typeof nativeId === "string")
|
|
2197
|
-
await recordHint(directory, root, {
|
|
2198
|
-
agent: AgentSchema.parse(values.agent),
|
|
2199
|
-
...typeof file === "string" && file ? { path: resolve4(file) } : {},
|
|
2200
|
-
...typeof nativeId === "string" ? { nativeId } : {},
|
|
2201
|
-
projectDir
|
|
2202
|
-
});
|
|
3387
|
+
});
|
|
3388
|
+
if (values.help) {
|
|
3389
|
+
process.stdout.write(help);
|
|
2203
3390
|
return 0;
|
|
2204
3391
|
}
|
|
3392
|
+
if (command === "teardown") {
|
|
3393
|
+
const directory2 = await realpath6(resolve8(values["state-dir"]));
|
|
3394
|
+
const root2 = values.all ? void 0 : await realpath6(resolve8(values.root)).catch(() => resolve8(values.root));
|
|
3395
|
+
const report = await teardownSessions(directory2, root2);
|
|
3396
|
+
process.stdout.write(JSON.stringify(report, null, 2) + "\n");
|
|
3397
|
+
return report.issues.length ? 1 : 0;
|
|
3398
|
+
}
|
|
3399
|
+
const root = await realpath6(resolve8(values.root));
|
|
3400
|
+
const directory = await stateLocation(root, values["state-dir"]);
|
|
3401
|
+
const projectDir = await realpath6(resolve8(values["project-dir"] ?? dirname9(root)));
|
|
3402
|
+
await prepareClient(directory, version);
|
|
2205
3403
|
const state = new ClientState(directory);
|
|
2206
3404
|
try {
|
|
2207
3405
|
const binding = state.binding(root);
|
|
@@ -2209,10 +3407,10 @@ async function runSessionCommand(args) {
|
|
|
2209
3407
|
const sessions = new SessionState(state, root);
|
|
2210
3408
|
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
2211
3409
|
let output;
|
|
2212
|
-
if (command === "setup") output = await setupSessions(state, root, directory);
|
|
3410
|
+
if (command === "setup") output = await setupSessions(state, root, directory, { enable: true });
|
|
2213
3411
|
else if (command === "track" || command === "untrack") {
|
|
2214
3412
|
if (!values.file) throw new Error("--file is required.");
|
|
2215
|
-
const path =
|
|
3413
|
+
const path = resolve8(values.file);
|
|
2216
3414
|
if (command === "track")
|
|
2217
3415
|
sessions.track(
|
|
2218
3416
|
AgentSchema.parse(values.agent),
|
|
@@ -2225,26 +3423,30 @@ async function runSessionCommand(args) {
|
|
|
2225
3423
|
} else if (command === "discover" || command === "undiscover") {
|
|
2226
3424
|
if (command === "undiscover") {
|
|
2227
3425
|
if (!values.directory) throw new Error("--directory is required.");
|
|
2228
|
-
|
|
3426
|
+
const source = await realpath6(resolve8(values.directory));
|
|
3427
|
+
setDiscoveryPreference(directory, root, source, false);
|
|
3428
|
+
state.db.prepare("DELETE FROM session_discovery WHERE root=? AND directory=?").run(root, source);
|
|
2229
3429
|
} else {
|
|
2230
3430
|
const agent = AgentSchema.parse(values.agent);
|
|
2231
3431
|
const defaults = {
|
|
2232
|
-
"claude-code":
|
|
2233
|
-
process.env.CLAUDE_CONFIG_DIR ??
|
|
3432
|
+
"claude-code": join16(
|
|
3433
|
+
process.env.CLAUDE_CONFIG_DIR ?? join16(homedir4(), ".claude"),
|
|
2234
3434
|
"projects"
|
|
2235
3435
|
),
|
|
2236
|
-
codex:
|
|
2237
|
-
cursor:
|
|
2238
|
-
process.env.CURSOR_CONFIG_DIR ??
|
|
3436
|
+
codex: join16(process.env.CODEX_HOME ?? join16(homedir4(), ".codex"), "sessions"),
|
|
3437
|
+
cursor: join16(
|
|
3438
|
+
process.env.CURSOR_CONFIG_DIR ?? join16(homedir4(), ".cursor"),
|
|
2239
3439
|
"chats",
|
|
2240
|
-
|
|
3440
|
+
createHash8("md5").update(projectDir).digest("hex")
|
|
2241
3441
|
),
|
|
2242
|
-
pi:
|
|
3442
|
+
pi: join16(process.env.PI_CODING_AGENT_DIR ?? join16(homedir4(), ".pi", "agent"), "sessions"),
|
|
2243
3443
|
dsh: void 0
|
|
2244
3444
|
};
|
|
2245
3445
|
const path = values.directory ?? defaults[agent];
|
|
2246
3446
|
if (!path) throw new Error("DSH requires its configured persistence --directory.");
|
|
2247
|
-
|
|
3447
|
+
const source = await realpath6(resolve8(path));
|
|
3448
|
+
setDiscoveryPreference(directory, root, source, true);
|
|
3449
|
+
sessions.discover(agent, source, projectDir);
|
|
2248
3450
|
}
|
|
2249
3451
|
output = sessions.directories();
|
|
2250
3452
|
} else if (command === "sources")
|
|
@@ -2283,54 +3485,33 @@ async function runSessionCommand(args) {
|
|
|
2283
3485
|
}
|
|
2284
3486
|
|
|
2285
3487
|
// apps/client/sync-command.ts
|
|
2286
|
-
import { parseArgs as
|
|
3488
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
2287
3489
|
import { setTimeout as setTimeout4 } from "node:timers/promises";
|
|
2288
3490
|
|
|
2289
3491
|
// apps/client/client-command.ts
|
|
2290
|
-
import { parseArgs as
|
|
2291
|
-
import { join as
|
|
2292
|
-
import { homedir as
|
|
3492
|
+
import { parseArgs as parseArgs3 } from "node:util";
|
|
3493
|
+
import { join as join18, resolve as resolve10 } from "node:path";
|
|
3494
|
+
import { homedir as homedir5 } from "node:os";
|
|
2293
3495
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
2294
3496
|
|
|
2295
3497
|
// apps/client/connection.ts
|
|
2296
|
-
import { readFile as
|
|
3498
|
+
import { readFile as readFile5, mkdir as mkdir10 } from "node:fs/promises";
|
|
2297
3499
|
import { createInterface } from "node:readline/promises";
|
|
2298
3500
|
|
|
2299
|
-
// apps/client/root-lock.ts
|
|
2300
|
-
import { createHash as createHash8 } from "node:crypto";
|
|
2301
|
-
import { join as join8 } from "node:path";
|
|
2302
|
-
function acquireRootLock(root, stateDirectory) {
|
|
2303
|
-
let lock;
|
|
2304
|
-
try {
|
|
2305
|
-
lock = openDatabase(
|
|
2306
|
-
join8(stateDirectory, "locks", `${createHash8("sha256").update(root).digest("hex")}.sqlite3`),
|
|
2307
|
-
[],
|
|
2308
|
-
0
|
|
2309
|
-
);
|
|
2310
|
-
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
2311
|
-
return lock;
|
|
2312
|
-
} catch (error) {
|
|
2313
|
-
lock?.close();
|
|
2314
|
-
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
2315
|
-
throw new Error("Another sync or connect process is using this root.");
|
|
2316
|
-
throw error;
|
|
2317
|
-
}
|
|
2318
|
-
}
|
|
2319
|
-
|
|
2320
3501
|
// apps/client/local-config.ts
|
|
2321
3502
|
import { constants as constants3 } from "node:fs";
|
|
2322
|
-
import { open as open3, writeFile as
|
|
2323
|
-
import { join as
|
|
2324
|
-
import { z as
|
|
3503
|
+
import { open as open3, writeFile as writeFile3 } from "node:fs/promises";
|
|
3504
|
+
import { join as join17 } from "node:path";
|
|
3505
|
+
import { z as z14 } from "zod";
|
|
2325
3506
|
import ignore from "ignore";
|
|
2326
3507
|
var LOCAL_CONFIG_NAME = ".teammy-local.json";
|
|
2327
|
-
var LocalConfigSchema =
|
|
2328
|
-
files:
|
|
3508
|
+
var LocalConfigSchema = z14.object({
|
|
3509
|
+
files: z14.object({ ignore: z14.array(z14.string().max(1024)).max(256).default([]) }).strict().default({ ignore: [] })
|
|
2329
3510
|
}).strict();
|
|
2330
3511
|
async function ensureLocalConfig(root) {
|
|
2331
3512
|
try {
|
|
2332
|
-
await
|
|
2333
|
-
|
|
3513
|
+
await writeFile3(
|
|
3514
|
+
join17(root, LOCAL_CONFIG_NAME),
|
|
2334
3515
|
JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
|
|
2335
3516
|
{ flag: "wx", mode: 384 }
|
|
2336
3517
|
);
|
|
@@ -2342,7 +3523,7 @@ async function loadLocalRules(root) {
|
|
|
2342
3523
|
let rules = [];
|
|
2343
3524
|
try {
|
|
2344
3525
|
const file = await open3(
|
|
2345
|
-
|
|
3526
|
+
join17(root, LOCAL_CONFIG_NAME),
|
|
2346
3527
|
constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
|
|
2347
3528
|
);
|
|
2348
3529
|
try {
|
|
@@ -2388,279 +3569,77 @@ async function connectProject(state, root, directory, options) {
|
|
|
2388
3569
|
if (existing && options.project && options.project !== existing.workspaceId)
|
|
2389
3570
|
throw new Error("This root belongs to a different project. Use a different local root.");
|
|
2390
3571
|
const info = await transport.serverInfo();
|
|
2391
|
-
if (!info.capabilities.includes("
|
|
3572
|
+
if (!info.capabilities.includes("atomic-file-publications"))
|
|
2392
3573
|
throw new Error(
|
|
2393
|
-
"
|
|
2394
|
-
);
|
|
2395
|
-
let member = options.member;
|
|
2396
|
-
if (existing && !options.reconnect && !options.tokenFile) {
|
|
2397
|
-
if (member && member !== existing.memberId)
|
|
2398
|
-
throw new Error("This root belongs to a different member.");
|
|
2399
|
-
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
2400
|
-
await client2.members();
|
|
2401
|
-
} else if (options.tokenFile) {
|
|
2402
|
-
const token = CredentialSchema.parse((await readFile2(options.tokenFile, "utf8")).trim());
|
|
2403
|
-
const deviceId = state.deviceFor(root);
|
|
2404
|
-
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
2405
|
-
const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
|
|
2406
|
-
if (options.project && binding2.workspaceId !== options.project)
|
|
2407
|
-
throw new Error("Token belongs to a different project.");
|
|
2408
|
-
state.saveBinding(root, client2.url, token, binding2);
|
|
2409
|
-
} else {
|
|
2410
|
-
member ??= existing?.memberId;
|
|
2411
|
-
if (!member && process.stdin.isTTY) {
|
|
2412
|
-
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2413
|
-
try {
|
|
2414
|
-
member = (await rl.question("Member ID: ")).trim();
|
|
2415
|
-
} finally {
|
|
2416
|
-
rl.close();
|
|
2417
|
-
}
|
|
2418
|
-
}
|
|
2419
|
-
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
2420
|
-
NameSchema.parse(member);
|
|
2421
|
-
if (existing && existing.memberId !== member)
|
|
2422
|
-
throw new Error("This root belongs to a different member.");
|
|
2423
|
-
const projectId = options.project ?? existing?.workspaceId;
|
|
2424
|
-
if (!projectId)
|
|
2425
|
-
throw new Error(
|
|
2426
|
-
"--project is required for a new connection; copy the command from the project settings."
|
|
2427
|
-
);
|
|
2428
|
-
NameSchema.parse(projectId);
|
|
2429
|
-
const request = project.enrollment(
|
|
2430
|
-
transport.url,
|
|
2431
|
-
member,
|
|
2432
|
-
projectId,
|
|
2433
|
-
Boolean(options.reconnect)
|
|
3574
|
+
"Update the server before connecting: complete directory publications are required."
|
|
2434
3575
|
);
|
|
2435
|
-
|
|
2436
|
-
|
|
2437
|
-
|
|
2438
|
-
|
|
2439
|
-
|
|
2440
|
-
|
|
2441
|
-
|
|
2442
|
-
|
|
2443
|
-
|
|
2444
|
-
|
|
2445
|
-
|
|
2446
|
-
|
|
2447
|
-
|
|
2448
|
-
|
|
2449
|
-
|
|
2450
|
-
|
|
2451
|
-
|
|
2452
|
-
|
|
2453
|
-
|
|
2454
|
-
|
|
2455
|
-
|
|
2456
|
-
|
|
2457
|
-
|
|
2458
|
-
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2463
|
-
|
|
2464
|
-
|
|
2465
|
-
|
|
2466
|
-
|
|
2467
|
-
|
|
2468
|
-
|
|
2469
|
-
|
|
2470
|
-
|
|
2471
|
-
|
|
2472
|
-
|
|
2473
|
-
|
|
2474
|
-
|
|
2475
|
-
|
|
2476
|
-
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
await chmod(path, 448);
|
|
2483
|
-
}
|
|
2484
|
-
async function daemonRequest(directory, input, timeout = 15e3) {
|
|
2485
|
-
RequestSchema.parse(input);
|
|
2486
|
-
return new Promise((resolve10, reject) => {
|
|
2487
|
-
const socket = createConnection(socketPath(directory));
|
|
2488
|
-
socket.setEncoding("utf8");
|
|
2489
|
-
let body = "";
|
|
2490
|
-
let done = false;
|
|
2491
|
-
const finish = (error, result2) => {
|
|
2492
|
-
if (done) return;
|
|
2493
|
-
done = true;
|
|
2494
|
-
socket.destroy();
|
|
2495
|
-
if (error) reject(error);
|
|
2496
|
-
else resolve10(result2);
|
|
2497
|
-
};
|
|
2498
|
-
socket.setTimeout(
|
|
2499
|
-
timeout,
|
|
2500
|
-
() => finish(new Error("Background service did not respond. Run teammy status."))
|
|
2501
|
-
);
|
|
2502
|
-
socket.on("error", (e) => finish(e));
|
|
2503
|
-
socket.on("end", () => {
|
|
2504
|
-
if (!done)
|
|
2505
|
-
finish(
|
|
2506
|
-
Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
|
|
2507
|
-
);
|
|
2508
|
-
});
|
|
2509
|
-
socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
|
|
2510
|
-
socket.on("data", (chunk) => {
|
|
2511
|
-
body += chunk.toString();
|
|
2512
|
-
if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
|
|
2513
|
-
if (!body.includes("\n")) return;
|
|
2514
|
-
try {
|
|
2515
|
-
const response = JSON.parse(body.split("\n")[0]);
|
|
2516
|
-
if (response.ok) finish(void 0, response.result);
|
|
2517
|
-
else finish(new Error(response.error ?? "Background operation failed."));
|
|
2518
|
-
} catch (e) {
|
|
2519
|
-
finish(e);
|
|
2520
|
-
}
|
|
2521
|
-
});
|
|
2522
|
-
});
|
|
2523
|
-
}
|
|
2524
|
-
async function runningDaemon(directory) {
|
|
2525
|
-
try {
|
|
2526
|
-
return await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2527
|
-
} catch (error) {
|
|
2528
|
-
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2529
|
-
String(error.code)
|
|
2530
|
-
))
|
|
2531
|
-
return null;
|
|
2532
|
-
throw error;
|
|
2533
|
-
}
|
|
2534
|
-
}
|
|
2535
|
-
|
|
2536
|
-
// apps/client/runtime/service.ts
|
|
2537
|
-
import { execFile as execFile3 } from "node:child_process";
|
|
2538
|
-
import { promisify as promisify3 } from "node:util";
|
|
2539
|
-
import { homedir as homedir3 } from "node:os";
|
|
2540
|
-
import { dirname as dirname6, join as join11, resolve as resolve5 } from "node:path";
|
|
2541
|
-
import { mkdir as mkdir8, readFile as readFile3, writeFile as writeFile3, rename as rename3 } from "node:fs/promises";
|
|
2542
|
-
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2543
|
-
var execute = promisify3(execFile3);
|
|
2544
|
-
var exec3 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
2545
|
-
var xml = (s) => s.replace(
|
|
2546
|
-
/[<>&"']/g,
|
|
2547
|
-
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
2548
|
-
);
|
|
2549
|
-
var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
|
|
2550
|
-
function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir3()) {
|
|
2551
|
-
for (const value of [directory, executable, entry])
|
|
2552
|
-
if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
|
|
2553
|
-
const id = `com.teammy.client.${rootKey(directory)}`;
|
|
2554
|
-
const args = [executable, entry, "_daemon", "--state-dir", directory];
|
|
2555
|
-
if (platform === "darwin")
|
|
2556
|
-
return {
|
|
2557
|
-
id,
|
|
2558
|
-
path: join11(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2559
|
-
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2560
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2561
|
-
<plist version="1.0"><dict><key>Label</key><string>${id}</string><key>ProgramArguments</key><array>${args.map((a) => `<string>${xml(a)}</string>`).join("")}</array><key>RunAtLoad</key><true/><key>KeepAlive</key><true/><key>ThrottleInterval</key><integer>30</integer><key>WorkingDirectory</key><string>${xml(userHome)}</string></dict></plist>
|
|
2562
|
-
`
|
|
2563
|
-
};
|
|
2564
|
-
if (platform === "linux")
|
|
2565
|
-
return {
|
|
2566
|
-
id: id + ".service",
|
|
2567
|
-
path: join11(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2568
|
-
content: `[Unit]
|
|
2569
|
-
Description=Teammy background synchronization
|
|
2570
|
-
[Service]
|
|
2571
|
-
Type=simple
|
|
2572
|
-
ExecStart=${args.map(unit).join(" ")}
|
|
2573
|
-
Restart=always
|
|
2574
|
-
RestartSec=30
|
|
2575
|
-
UMask=0077
|
|
2576
|
-
StandardOutput=null
|
|
2577
|
-
StandardError=journal
|
|
2578
|
-
[Install]
|
|
2579
|
-
WantedBy=default.target
|
|
2580
|
-
`
|
|
2581
|
-
};
|
|
2582
|
-
throw new Error(
|
|
2583
|
-
"Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
|
|
2584
|
-
);
|
|
2585
|
-
}
|
|
2586
|
-
async function checkServiceSupport() {
|
|
2587
|
-
try {
|
|
2588
|
-
if (process.platform === "darwin")
|
|
2589
|
-
await exec3("launchctl", ["print", `gui/${process.getuid()}`], {
|
|
2590
|
-
maxBuffer: 4 * 1024 * 1024
|
|
2591
|
-
});
|
|
2592
|
-
else if (process.platform === "linux") await exec3("systemctl", ["--user", "show-environment"]);
|
|
2593
|
-
else throw new Error("unsupported");
|
|
2594
|
-
} catch {
|
|
2595
|
-
throw new Error(
|
|
2596
|
-
"A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
|
|
2597
|
-
);
|
|
2598
|
-
}
|
|
2599
|
-
}
|
|
2600
|
-
async function bootstrapLaunchAgent(path) {
|
|
2601
|
-
const deadline = Date.now() + 1e4;
|
|
2602
|
-
for (; ; ) {
|
|
2603
|
-
try {
|
|
2604
|
-
await exec3("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
|
|
2605
|
-
return;
|
|
2606
|
-
} catch (error) {
|
|
2607
|
-
if (error.code !== 5 || Date.now() >= deadline) throw error;
|
|
2608
|
-
await setTimeout3(250);
|
|
2609
|
-
}
|
|
2610
|
-
}
|
|
2611
|
-
}
|
|
2612
|
-
async function ensureDaemon(directory, version) {
|
|
2613
|
-
const definition = serviceDefinition(directory, process.execPath, resolve5(process.argv[1]));
|
|
2614
|
-
let old = "";
|
|
2615
|
-
try {
|
|
2616
|
-
old = await readFile3(definition.path, "utf8");
|
|
2617
|
-
} catch (e) {
|
|
2618
|
-
if (e.code !== "ENOENT") throw e;
|
|
2619
|
-
}
|
|
2620
|
-
const alive = await runningDaemon(directory);
|
|
2621
|
-
if (alive && old === definition.content && alive.version === version) return;
|
|
2622
|
-
await checkServiceSupport();
|
|
2623
|
-
await mkdir8(dirname6(definition.path), { recursive: true });
|
|
2624
|
-
if (old !== definition.content) {
|
|
2625
|
-
const temp = definition.path + `.${process.pid}.tmp`;
|
|
2626
|
-
await writeFile3(temp, definition.content, { mode: 384 });
|
|
2627
|
-
await rename3(temp, definition.path);
|
|
2628
|
-
}
|
|
2629
|
-
if (process.platform === "darwin") {
|
|
2630
|
-
const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
|
|
2631
|
-
let loaded = false;
|
|
2632
|
-
try {
|
|
2633
|
-
await exec3("launchctl", ["print", target]);
|
|
2634
|
-
loaded = true;
|
|
2635
|
-
} catch {
|
|
3576
|
+
if (!info.capabilities.includes("multi-project"))
|
|
3577
|
+
throw new Error(
|
|
3578
|
+
"This client requires a multi-project server. Update the server before connecting."
|
|
3579
|
+
);
|
|
3580
|
+
let member = options.member;
|
|
3581
|
+
if (existing && !options.reconnect && !options.tokenFile) {
|
|
3582
|
+
if (member && member !== existing.memberId)
|
|
3583
|
+
throw new Error("This root belongs to a different member.");
|
|
3584
|
+
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
3585
|
+
await client2.members();
|
|
3586
|
+
} else if (options.tokenFile) {
|
|
3587
|
+
const token = CredentialSchema.parse((await readFile5(options.tokenFile, "utf8")).trim());
|
|
3588
|
+
const deviceId = state.deviceFor(root);
|
|
3589
|
+
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
3590
|
+
const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
|
|
3591
|
+
if (options.project && binding2.workspaceId !== options.project)
|
|
3592
|
+
throw new Error("Token belongs to a different project.");
|
|
3593
|
+
state.saveBinding(root, client2.url, token, binding2);
|
|
3594
|
+
} else {
|
|
3595
|
+
member ??= existing?.memberId;
|
|
3596
|
+
if (!member && process.stdin.isTTY) {
|
|
3597
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
3598
|
+
try {
|
|
3599
|
+
member = (await rl.question("Member ID: ")).trim();
|
|
3600
|
+
} finally {
|
|
3601
|
+
rl.close();
|
|
3602
|
+
}
|
|
3603
|
+
}
|
|
3604
|
+
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
3605
|
+
NameSchema.parse(member);
|
|
3606
|
+
if (existing && existing.memberId !== member)
|
|
3607
|
+
throw new Error("This root belongs to a different member.");
|
|
3608
|
+
const projectId = options.project ?? existing?.workspaceId;
|
|
3609
|
+
if (!projectId)
|
|
3610
|
+
throw new Error(
|
|
3611
|
+
"--project is required for a new connection; copy the command from the project settings."
|
|
3612
|
+
);
|
|
3613
|
+
NameSchema.parse(projectId);
|
|
3614
|
+
const request = project.enrollment(
|
|
3615
|
+
transport.url,
|
|
3616
|
+
member,
|
|
3617
|
+
projectId,
|
|
3618
|
+
Boolean(options.reconnect)
|
|
3619
|
+
);
|
|
3620
|
+
const binding2 = await transport.enroll(request);
|
|
3621
|
+
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
3622
|
+
project.completeEnrollment();
|
|
2636
3623
|
}
|
|
2637
|
-
|
|
2638
|
-
|
|
2639
|
-
|
|
3624
|
+
const binding = state.binding(root);
|
|
3625
|
+
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
3626
|
+
if (info.capabilities.includes("project-config")) {
|
|
3627
|
+
try {
|
|
3628
|
+
await applyProjectFile(project, await client.projectConfig());
|
|
3629
|
+
} catch (error) {
|
|
3630
|
+
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
3631
|
+
throw error;
|
|
3632
|
+
}
|
|
2640
3633
|
}
|
|
2641
|
-
|
|
2642
|
-
|
|
2643
|
-
|
|
2644
|
-
await
|
|
2645
|
-
|
|
2646
|
-
|
|
2647
|
-
|
|
2648
|
-
|
|
2649
|
-
definition.id
|
|
2650
|
-
]);
|
|
2651
|
-
}
|
|
2652
|
-
const deadline = Date.now() + 15e3;
|
|
2653
|
-
while (Date.now() < deadline) {
|
|
2654
|
-
if ((await runningDaemon(directory))?.version === version) return;
|
|
2655
|
-
await setTimeout3(150);
|
|
3634
|
+
const own = await metadata(root, binding.memberId);
|
|
3635
|
+
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
3636
|
+
if (!own) await mkdir10(await safePath(root, binding.memberId));
|
|
3637
|
+
await ensureLocalConfig(root);
|
|
3638
|
+
const { token: _, ...visible } = binding;
|
|
3639
|
+
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
3640
|
+
} finally {
|
|
3641
|
+
lock.close();
|
|
2656
3642
|
}
|
|
2657
|
-
throw new Error(
|
|
2658
|
-
"Background service did not start. Run teammy logs and check the user service manager."
|
|
2659
|
-
);
|
|
2660
|
-
}
|
|
2661
|
-
async function wakeDaemon(directory, version) {
|
|
2662
|
-
await ensureDaemon(directory, version);
|
|
2663
|
-
await daemonRequest(directory, { method: "wake" });
|
|
2664
3643
|
}
|
|
2665
3644
|
|
|
2666
3645
|
// apps/client/output.ts
|
|
@@ -2680,15 +3659,94 @@ function json(value) {
|
|
|
2680
3659
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
2681
3660
|
}
|
|
2682
3661
|
|
|
3662
|
+
// apps/client/runtime/diagnostics.ts
|
|
3663
|
+
import { resolve as resolve9 } from "node:path";
|
|
3664
|
+
import { realpath as realpath7 } from "node:fs/promises";
|
|
3665
|
+
async function runDiagnostics(command, options, version) {
|
|
3666
|
+
const directory = await realpath7(options.directory).catch(() => resolve9(options.directory));
|
|
3667
|
+
const root = await realpath7(options.root).catch(() => resolve9(options.root));
|
|
3668
|
+
const log = new ClientLog(directory);
|
|
3669
|
+
if (command === "logs") {
|
|
3670
|
+
const entries = log.tail(options.all ? void 0 : root);
|
|
3671
|
+
if (options.json) json(entries);
|
|
3672
|
+
else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
|
|
3673
|
+
else
|
|
3674
|
+
for (const entry of entries)
|
|
3675
|
+
process.stdout.write(
|
|
3676
|
+
`${entry.at} ${entry.event}${entry.detail ? " " + entry.detail : ""}
|
|
3677
|
+
`
|
|
3678
|
+
);
|
|
3679
|
+
return 0;
|
|
3680
|
+
}
|
|
3681
|
+
const probe = await probeDaemon(directory);
|
|
3682
|
+
let state;
|
|
3683
|
+
let issue = probe.state === "unresponsive" || probe.state === "incompatible" ? probe.error : null;
|
|
3684
|
+
let current = null;
|
|
3685
|
+
let binding = null;
|
|
3686
|
+
let control = null;
|
|
3687
|
+
let maintenance = null;
|
|
3688
|
+
try {
|
|
3689
|
+
maintenance = readMaintenance(directory);
|
|
3690
|
+
} catch (e) {
|
|
3691
|
+
issue ??= diagnostic(e);
|
|
3692
|
+
}
|
|
3693
|
+
try {
|
|
3694
|
+
state = new ClientState(directory, { readOnly: true });
|
|
3695
|
+
const stored = state.binding(root);
|
|
3696
|
+
if (stored) {
|
|
3697
|
+
const { token: _, ...safe } = stored;
|
|
3698
|
+
binding = safe;
|
|
3699
|
+
const runtime = new RuntimeState(state);
|
|
3700
|
+
current = { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
|
|
3701
|
+
control = readControlStatus(state, root);
|
|
3702
|
+
if (probe.state === "ready") {
|
|
3703
|
+
try {
|
|
3704
|
+
current = await daemonRequest(directory, { method: "status", root }, 1e3);
|
|
3705
|
+
} catch (e) {
|
|
3706
|
+
issue ??= diagnostic(e);
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
}
|
|
3710
|
+
} catch (e) {
|
|
3711
|
+
issue ??= diagnostic(e);
|
|
3712
|
+
} finally {
|
|
3713
|
+
state?.close();
|
|
3714
|
+
}
|
|
3715
|
+
if (options.json)
|
|
3716
|
+
json({
|
|
3717
|
+
...current,
|
|
3718
|
+
binding,
|
|
3719
|
+
control,
|
|
3720
|
+
daemon: probe.state === "ready" ? probe.info : null,
|
|
3721
|
+
daemonState: probe.state,
|
|
3722
|
+
installedVersion: version,
|
|
3723
|
+
maintenance,
|
|
3724
|
+
issue,
|
|
3725
|
+
logPath: log.path
|
|
3726
|
+
});
|
|
3727
|
+
else {
|
|
3728
|
+
const title = maintenance ? "\u540E\u53F0\u540C\u6B65\u5DF2\u6682\u505C" : issue ? "\u540C\u6B65\u9700\u8981\u5904\u7406" : !binding ? "\u5F53\u524D\u76EE\u5F55\u5C1A\u672A\u8FDE\u63A5" : !current?.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : current.status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : "\u540C\u6B65\u5DF2\u5F00\u542F";
|
|
3729
|
+
result(
|
|
3730
|
+
title,
|
|
3731
|
+
`\u5B89\u88C5 ${version} \xB7 \u8FD0\u884C ${probe.state === "ready" ? probe.info.version : "\u672A\u5C31\u7EEA"}`
|
|
3732
|
+
);
|
|
3733
|
+
if (issue || current?.status?.issue) result("\u5F85\u5904\u7406", issue ?? current.status.issue);
|
|
3734
|
+
if (current?.status?.lastSuccessAt)
|
|
3735
|
+
process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${current.status.lastSuccessAt}
|
|
3736
|
+
`);
|
|
3737
|
+
}
|
|
3738
|
+
return issue ? 1 : 0;
|
|
3739
|
+
}
|
|
3740
|
+
|
|
2683
3741
|
// apps/client/client-command.ts
|
|
2684
3742
|
var locationOptions = {
|
|
2685
|
-
root: { type: "string", default:
|
|
2686
|
-
"state-dir": { type: "string", default:
|
|
3743
|
+
root: { type: "string", default: resolve10("workspaces") },
|
|
3744
|
+
"state-dir": { type: "string", default: join18(homedir5(), ".teammy", "client") },
|
|
2687
3745
|
json: { type: "boolean" },
|
|
2688
3746
|
help: { type: "boolean", short: "h" }
|
|
2689
3747
|
};
|
|
2690
3748
|
async function runClientCommand(command, args, version) {
|
|
2691
|
-
const { values } =
|
|
3749
|
+
const { values } = parseArgs3({
|
|
2692
3750
|
args,
|
|
2693
3751
|
options: {
|
|
2694
3752
|
...locationOptions,
|
|
@@ -2708,54 +3766,16 @@ async function runClientCommand(command, args, version) {
|
|
|
2708
3766
|
);
|
|
2709
3767
|
return 0;
|
|
2710
3768
|
}
|
|
3769
|
+
if (command === "logs" || command === "status")
|
|
3770
|
+
return runDiagnostics(
|
|
3771
|
+
command,
|
|
3772
|
+
{ root: values.root, directory: values["state-dir"], json: values.json, all: values.all },
|
|
3773
|
+
version
|
|
3774
|
+
);
|
|
2711
3775
|
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
3776
|
+
await prepareClient(directory, version, { resume: !values["no-start"] });
|
|
2712
3777
|
const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
|
|
2713
3778
|
try {
|
|
2714
|
-
if (command === "logs") {
|
|
2715
|
-
const entries = log.tail(values.all ? void 0 : root);
|
|
2716
|
-
if (values.json) json(entries);
|
|
2717
|
-
else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
|
|
2718
|
-
else
|
|
2719
|
-
for (const item of entries)
|
|
2720
|
-
process.stdout.write(
|
|
2721
|
-
`${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
|
|
2722
|
-
`
|
|
2723
|
-
);
|
|
2724
|
-
return 0;
|
|
2725
|
-
}
|
|
2726
|
-
if (command === "status") {
|
|
2727
|
-
const binding = state.binding(root);
|
|
2728
|
-
if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
|
|
2729
|
-
const daemon = await runningDaemon(directory);
|
|
2730
|
-
const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
|
|
2731
|
-
const { token: _, ...publicBinding } = binding;
|
|
2732
|
-
if (values.json)
|
|
2733
|
-
json({
|
|
2734
|
-
...current,
|
|
2735
|
-
binding: publicBinding,
|
|
2736
|
-
control: readControlStatus(state, root),
|
|
2737
|
-
daemon,
|
|
2738
|
-
logPath: log.path
|
|
2739
|
-
});
|
|
2740
|
-
else {
|
|
2741
|
-
const status = current.status;
|
|
2742
|
-
const title = !current.enabled ? "\u540C\u6B65\u5DF2\u505C\u6B62" : !current.running ? "\u540E\u53F0\u540C\u6B65\u672A\u8FD0\u884C" : status?.issue ? "\u540C\u6B65\u7B49\u5F85\u5904\u7406" : status?.pendingDownloads || !status?.lastSuccessAt ? "\u9996\u6B21\u540C\u6B65\u4E2D" : "\u540C\u6B65\u5DF2\u5F00\u542F";
|
|
2743
|
-
result(
|
|
2744
|
-
title,
|
|
2745
|
-
new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
|
|
2746
|
-
);
|
|
2747
|
-
if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
|
|
2748
|
-
else if (status?.lastSuccessAt)
|
|
2749
|
-
process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
|
|
2750
|
-
`);
|
|
2751
|
-
if (status?.pendingFiles || status?.pendingDownloads)
|
|
2752
|
-
process.stdout.write(
|
|
2753
|
-
` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
|
|
2754
|
-
`
|
|
2755
|
-
);
|
|
2756
|
-
}
|
|
2757
|
-
return 0;
|
|
2758
|
-
}
|
|
2759
3779
|
const existing = state.binding(root);
|
|
2760
3780
|
const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
|
|
2761
3781
|
if (!url) throw new Error("--url is required.");
|
|
@@ -2786,7 +3806,7 @@ async function runClientCommand(command, args, version) {
|
|
|
2786
3806
|
tokenFile: values["token-file"],
|
|
2787
3807
|
reconnect: values.reconnect
|
|
2788
3808
|
});
|
|
2789
|
-
const collection = await setupSessions(state, root, directory);
|
|
3809
|
+
const collection = await setupSessions(state, root, directory, { enable: true });
|
|
2790
3810
|
for (const issue of collection.issues) log.write("sessions.setup.failed", root, issue);
|
|
2791
3811
|
completePreparation(state, root);
|
|
2792
3812
|
runtime.enable(root, !values["no-start"]);
|
|
@@ -2795,7 +3815,7 @@ async function runClientCommand(command, args, version) {
|
|
|
2795
3815
|
if (values.json) json({ ...connected, collection, syncEnabled: !values["no-start"] });
|
|
2796
3816
|
else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
|
|
2797
3817
|
if (collection.issues.length && !values.json)
|
|
2798
|
-
result("\u90E8\u5206\
|
|
3818
|
+
result("\u90E8\u5206\u5BF9\u8BDD\u91C7\u96C6 Hook \u672A\u914D\u7F6E", "\u540E\u53F0\u53D1\u73B0\u4ECD\u53EF\u7528\uFF0C\u8BE6\u60C5\u89C1 teammy logs");
|
|
2799
3819
|
return 0;
|
|
2800
3820
|
} catch (e) {
|
|
2801
3821
|
log.write(`${command}.failed`, root, diagnostic(e));
|
|
@@ -2806,16 +3826,16 @@ async function runClientCommand(command, args, version) {
|
|
|
2806
3826
|
}
|
|
2807
3827
|
|
|
2808
3828
|
// apps/client/files/synchronizer.ts
|
|
2809
|
-
import { randomUUID as
|
|
3829
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2810
3830
|
import { watch } from "node:fs";
|
|
2811
|
-
import { lstat as lstat4, mkdir as
|
|
2812
|
-
import { join as
|
|
3831
|
+
import { lstat as lstat4, mkdir as mkdir12 } from "node:fs/promises";
|
|
3832
|
+
import { join as join20, sep as sep5 } from "node:path";
|
|
2813
3833
|
import { performance } from "node:perf_hooks";
|
|
2814
3834
|
|
|
2815
3835
|
// packages/storage/local-blob-store.ts
|
|
2816
|
-
import { createHash as createHash9, randomUUID as
|
|
2817
|
-
import { link, mkdir as
|
|
2818
|
-
import { dirname as
|
|
3836
|
+
import { createHash as createHash9, randomUUID as randomUUID6 } from "node:crypto";
|
|
3837
|
+
import { link, mkdir as mkdir11, open as open4, readFile as readFile6, unlink as unlink3 } from "node:fs/promises";
|
|
3838
|
+
import { dirname as dirname10, join as join19, resolve as resolve11 } from "node:path";
|
|
2819
3839
|
function hash(content) {
|
|
2820
3840
|
return createHash9("sha256").update(content).digest("hex");
|
|
2821
3841
|
}
|
|
@@ -2833,14 +3853,14 @@ var BlobIntegrityError = class extends Error {
|
|
|
2833
3853
|
var LocalBlobStore = class {
|
|
2834
3854
|
root;
|
|
2835
3855
|
constructor(root) {
|
|
2836
|
-
this.root =
|
|
3856
|
+
this.root = resolve11(root);
|
|
2837
3857
|
}
|
|
2838
3858
|
async put(content) {
|
|
2839
3859
|
const bytes = Buffer.from(content);
|
|
2840
3860
|
const sha256 = hash(bytes);
|
|
2841
3861
|
const target = this.pathFor(sha256);
|
|
2842
|
-
await
|
|
2843
|
-
const temporary = `${target}.${
|
|
3862
|
+
await mkdir11(dirname10(target), { recursive: true, mode: 448 });
|
|
3863
|
+
const temporary = `${target}.${randomUUID6()}.tmp`;
|
|
2844
3864
|
const file = await open4(temporary, "wx", 384);
|
|
2845
3865
|
try {
|
|
2846
3866
|
try {
|
|
@@ -2856,7 +3876,7 @@ var LocalBlobStore = class {
|
|
|
2856
3876
|
await this.get(sha256);
|
|
2857
3877
|
}
|
|
2858
3878
|
if (process.platform !== "win32") {
|
|
2859
|
-
let directory =
|
|
3879
|
+
let directory = dirname10(target);
|
|
2860
3880
|
for (; ; ) {
|
|
2861
3881
|
const handle = await open4(directory, "r");
|
|
2862
3882
|
try {
|
|
@@ -2864,8 +3884,8 @@ var LocalBlobStore = class {
|
|
|
2864
3884
|
} finally {
|
|
2865
3885
|
await handle.close();
|
|
2866
3886
|
}
|
|
2867
|
-
if (directory ===
|
|
2868
|
-
directory =
|
|
3887
|
+
if (directory === dirname10(this.root)) break;
|
|
3888
|
+
directory = dirname10(directory);
|
|
2869
3889
|
}
|
|
2870
3890
|
}
|
|
2871
3891
|
} finally {
|
|
@@ -2877,7 +3897,7 @@ var LocalBlobStore = class {
|
|
|
2877
3897
|
const target = this.pathFor(sha256);
|
|
2878
3898
|
let content;
|
|
2879
3899
|
try {
|
|
2880
|
-
content = await
|
|
3900
|
+
content = await readFile6(target);
|
|
2881
3901
|
} catch (error) {
|
|
2882
3902
|
if (hasCode(error, "ENOENT")) return void 0;
|
|
2883
3903
|
throw error;
|
|
@@ -2887,7 +3907,7 @@ var LocalBlobStore = class {
|
|
|
2887
3907
|
}
|
|
2888
3908
|
pathFor(sha256) {
|
|
2889
3909
|
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
2890
|
-
return
|
|
3910
|
+
return join19(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
2891
3911
|
}
|
|
2892
3912
|
};
|
|
2893
3913
|
|
|
@@ -2905,14 +3925,17 @@ var PublicationQueue = class {
|
|
|
2905
3925
|
const pending = this.state.pending(root);
|
|
2906
3926
|
if (!pending || pending.retryAt > now) return false;
|
|
2907
3927
|
try {
|
|
3928
|
+
let end = pending.nextIndex, bytes = 0;
|
|
3929
|
+
while (end < pending.batch.changes.length && end - pending.nextIndex < MAX_BATCH_FILES && bytes < 20 * 1024 * 1024) {
|
|
3930
|
+
const change = pending.batch.changes[end++];
|
|
3931
|
+
if (change.kind === "upsert") bytes += change.size;
|
|
3932
|
+
}
|
|
2908
3933
|
const hashes = [
|
|
2909
3934
|
...new Set(
|
|
2910
|
-
pending.batch.changes.flatMap(
|
|
2911
|
-
(change) => change.kind === "upsert" ? [change.sha256] : []
|
|
2912
|
-
)
|
|
3935
|
+
pending.batch.changes.slice(pending.nextIndex, end).flatMap((change) => change.kind === "upsert" ? [change.sha256] : [])
|
|
2913
3936
|
)
|
|
2914
3937
|
];
|
|
2915
|
-
const { missing: missing2 } = await this.transport.missingBlobs(hashes);
|
|
3938
|
+
const { missing: missing2 } = hashes.length ? await this.transport.missingBlobs(hashes) : { missing: [] };
|
|
2916
3939
|
for (const hash2 of missing2) {
|
|
2917
3940
|
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2918
3941
|
const content = await this.blobs.get(hash2);
|
|
@@ -2921,9 +3944,12 @@ var PublicationQueue = class {
|
|
|
2921
3944
|
if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
|
|
2922
3945
|
throw new Error("Server acknowledged different content.");
|
|
2923
3946
|
}
|
|
3947
|
+
this.state.uploaded(root, pending.batch.operationId, end);
|
|
3948
|
+
if (end < pending.batch.changes.length) return false;
|
|
2924
3949
|
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
2925
3950
|
return true;
|
|
2926
3951
|
} catch (error) {
|
|
3952
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
2927
3953
|
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
2928
3954
|
this.state.postpone(
|
|
2929
3955
|
root,
|
|
@@ -2947,17 +3973,17 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2947
3973
|
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2948
3974
|
this.binding = binding;
|
|
2949
3975
|
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2950
|
-
this.blobs = new LocalBlobStore(
|
|
3976
|
+
this.blobs = new LocalBlobStore(join20(stateDirectory, "blobs"));
|
|
2951
3977
|
this.projectScope = options.scope === "project";
|
|
2952
3978
|
this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
|
|
2953
3979
|
this.publication = new FilePublicationState(state.db, options.scope);
|
|
2954
3980
|
this.queue = new PublicationQueue(this.publication, this.blobs, {
|
|
2955
3981
|
missingBlobs: (hashes) => this.client.missingBlobs(hashes),
|
|
2956
3982
|
uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
|
|
2957
|
-
commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch,
|
|
3983
|
+
commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, batch.configRevision) : this.client.commitFiles(batch)
|
|
2958
3984
|
});
|
|
2959
3985
|
this.index = new SyncState(state, root, options.scope);
|
|
2960
|
-
this.ownRoot = this.projectScope ? root :
|
|
3986
|
+
this.ownRoot = this.projectScope ? root : join20(root, binding.memberId);
|
|
2961
3987
|
this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
|
|
2962
3988
|
const started = performance.now();
|
|
2963
3989
|
const epoch = Date.now();
|
|
@@ -2987,14 +4013,16 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2987
4013
|
watchIssues = [];
|
|
2988
4014
|
lastScan = -Infinity;
|
|
2989
4015
|
busy = false;
|
|
4016
|
+
capable = false;
|
|
2990
4017
|
stopped = false;
|
|
2991
4018
|
abort = new AbortController();
|
|
2992
4019
|
async checkCapabilities() {
|
|
2993
4020
|
const info = await this.client.serverInfo();
|
|
2994
|
-
if (!["identity", "blobs", "file-revisions"].every(
|
|
4021
|
+
if (!["identity", "blobs", "file-revisions", "atomic-file-publications"].every(
|
|
2995
4022
|
(capability) => info.capabilities.includes(capability)
|
|
2996
4023
|
))
|
|
2997
|
-
throw new Error("
|
|
4024
|
+
throw new Error("Upgrade the server to support complete directory publications.");
|
|
4025
|
+
this.capable = true;
|
|
2998
4026
|
}
|
|
2999
4027
|
setSettleMs(value) {
|
|
3000
4028
|
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
@@ -3022,6 +4050,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3022
4050
|
};
|
|
3023
4051
|
try {
|
|
3024
4052
|
try {
|
|
4053
|
+
if (!this.capable) await this.checkCapabilities();
|
|
3025
4054
|
if (this.projectScope) {
|
|
3026
4055
|
try {
|
|
3027
4056
|
this.sharedConfig = await this.client.sharedFilesConfig();
|
|
@@ -3081,11 +4110,10 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3081
4110
|
}
|
|
3082
4111
|
const blocked = await this.refreshDirty(report.issues);
|
|
3083
4112
|
await this.audit(report.issues);
|
|
3084
|
-
report.published = await this.queue.flush(this.root, this.clock());
|
|
3085
4113
|
if (!this.publication.pending(this.root) && !this.stopped) {
|
|
3086
4114
|
await this.stage(blocked, report.issues, settleMs);
|
|
3087
|
-
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
3088
4115
|
}
|
|
4116
|
+
report.published = await this.queue.flush(this.root, this.clock());
|
|
3089
4117
|
} catch (error) {
|
|
3090
4118
|
report.issues.push(`publish: ${message(error)}`);
|
|
3091
4119
|
}
|
|
@@ -3129,7 +4157,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3129
4157
|
throw new Error("Manifest changed version during pagination.");
|
|
3130
4158
|
revision = page.revision;
|
|
3131
4159
|
files.push(...page.files);
|
|
3132
|
-
if (page.nextCursor === null) return { revision, files };
|
|
4160
|
+
if (page.nextCursor === null) return { revision, complete: page.complete, files };
|
|
3133
4161
|
if (page.nextCursor === after || files.length > 1e5)
|
|
3134
4162
|
throw new Error("Manifest pagination did not make bounded progress.");
|
|
3135
4163
|
after = page.nextCursor;
|
|
@@ -3156,10 +4184,12 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3156
4184
|
}
|
|
3157
4185
|
}
|
|
3158
4186
|
const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
|
|
3159
|
-
await
|
|
4187
|
+
await mkdir12(ownerPath).catch((error) => {
|
|
3160
4188
|
if (error.code !== "EEXIST") throw error;
|
|
3161
4189
|
});
|
|
3162
|
-
this.publication.initializePublication(this.root, manifest.revision, manifest.files
|
|
4190
|
+
this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
|
|
4191
|
+
complete: manifest.complete
|
|
4192
|
+
});
|
|
3163
4193
|
}
|
|
3164
4194
|
listen() {
|
|
3165
4195
|
if (this.options.watch === false || this.watcher || this.stopped) return;
|
|
@@ -3169,7 +4199,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3169
4199
|
this.lastScan = -Infinity;
|
|
3170
4200
|
return;
|
|
3171
4201
|
}
|
|
3172
|
-
const path = filename.toString().split(
|
|
4202
|
+
const path = filename.toString().split(sep5).join("/");
|
|
3173
4203
|
if (!this.excludes(path)) this.index.event(path, this.clock());
|
|
3174
4204
|
});
|
|
3175
4205
|
this.watcher.on("error", (error) => {
|
|
@@ -3193,6 +4223,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3193
4223
|
}
|
|
3194
4224
|
}
|
|
3195
4225
|
issues.push(...scan.issues);
|
|
4226
|
+
return scan.complete;
|
|
3196
4227
|
}
|
|
3197
4228
|
async refreshDirty(issues) {
|
|
3198
4229
|
const blocked = /* @__PURE__ */ new Set();
|
|
@@ -3233,73 +4264,80 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3233
4264
|
}
|
|
3234
4265
|
}
|
|
3235
4266
|
async stage(blocked, issues, settleMs) {
|
|
3236
|
-
const
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
if (!
|
|
4267
|
+
const publication = this.publication.publication(this.root);
|
|
4268
|
+
if (publication.complete && !this.index.index(true).some((row) => !this.excludes(row.path)))
|
|
4269
|
+
return;
|
|
4270
|
+
if (!await this.reconcile("", issues)) return;
|
|
4271
|
+
const before = this.index.index().filter((row) => !this.excludes(row.path));
|
|
4272
|
+
const ready = before.filter((row) => row.dirty);
|
|
4273
|
+
if (ready.some(
|
|
4274
|
+
(row) => blocked.has(row.path) || row.eventPending || this.clock() - row.observedAt < settleMs
|
|
4275
|
+
))
|
|
4276
|
+
return;
|
|
3240
4277
|
const baseline = this.publication.ownerFiles(this.root);
|
|
3241
4278
|
const planned = new Map(baseline);
|
|
3242
4279
|
const changes = [];
|
|
3243
4280
|
const snapshots = [];
|
|
3244
|
-
let bytes = 0;
|
|
3245
4281
|
ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
|
|
3246
4282
|
for (const row of ready) {
|
|
3247
|
-
if (this.stopped
|
|
4283
|
+
if (this.stopped) return;
|
|
3248
4284
|
try {
|
|
3249
4285
|
if (row.signature === null) {
|
|
3250
|
-
const current = await metadata(this.ownRoot, row.path);
|
|
3251
|
-
if (current && !current.directory) {
|
|
3252
|
-
this.index.event(row.path, this.clock());
|
|
3253
|
-
continue;
|
|
3254
|
-
}
|
|
3255
4286
|
if (baseline.has(row.path) && row.seenLocal) {
|
|
3256
4287
|
changes.push({ kind: "delete", path: row.path });
|
|
3257
4288
|
planned.delete(row.path);
|
|
3258
|
-
} else {
|
|
3259
|
-
this.index.clean(row.path, row.generation, null, this.clock());
|
|
3260
|
-
continue;
|
|
3261
4289
|
}
|
|
3262
4290
|
snapshots.push({ path: row.path, generation: row.generation, hash: null });
|
|
3263
4291
|
continue;
|
|
3264
4292
|
}
|
|
3265
|
-
const key = row.path.toLowerCase();
|
|
3266
|
-
const collision = [...planned.keys()].some(
|
|
3267
|
-
(path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
|
|
3268
|
-
);
|
|
3269
|
-
if (collision) {
|
|
3270
|
-
issues.push(
|
|
3271
|
-
`${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
|
|
3272
|
-
);
|
|
3273
|
-
continue;
|
|
3274
|
-
}
|
|
3275
4293
|
const local = await readStable(this.ownRoot, row.path);
|
|
3276
|
-
bytes += local.bytes.length;
|
|
3277
4294
|
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
3278
4295
|
this.index.event(row.path, this.clock());
|
|
3279
|
-
|
|
4296
|
+
return;
|
|
3280
4297
|
}
|
|
3281
|
-
if (baseline.get(row.path)?.sha256
|
|
3282
|
-
this.
|
|
3283
|
-
|
|
4298
|
+
if (baseline.get(row.path)?.sha256 !== local.sha256) {
|
|
4299
|
+
const ref = await this.blobs.put(local.bytes);
|
|
4300
|
+
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
4301
|
+
planned.set(row.path, ref);
|
|
3284
4302
|
}
|
|
3285
|
-
const ref = await this.blobs.put(local.bytes);
|
|
3286
|
-
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
3287
|
-
planned.set(row.path, ref);
|
|
3288
4303
|
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
3289
4304
|
} catch (error) {
|
|
3290
4305
|
this.index.event(row.path, this.clock());
|
|
3291
4306
|
issues.push(`${row.path}: ${message(error)}`);
|
|
4307
|
+
return;
|
|
3292
4308
|
}
|
|
3293
4309
|
}
|
|
3294
|
-
|
|
3295
|
-
const
|
|
3296
|
-
|
|
3297
|
-
|
|
3298
|
-
|
|
3299
|
-
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
}
|
|
4310
|
+
const keys = /* @__PURE__ */ new Set();
|
|
4311
|
+
for (const path of planned.keys()) {
|
|
4312
|
+
const key = path.toLowerCase();
|
|
4313
|
+
if (keys.has(key)) {
|
|
4314
|
+
issues.push(`${path}: conflicts with a preserved server path.`);
|
|
4315
|
+
return;
|
|
4316
|
+
}
|
|
4317
|
+
keys.add(key);
|
|
4318
|
+
}
|
|
4319
|
+
for (const key of keys) {
|
|
4320
|
+
const parts = key.split("/");
|
|
4321
|
+
for (let i = 1; i < parts.length; i++) {
|
|
4322
|
+
if (keys.has(parts.slice(0, i).join("/"))) {
|
|
4323
|
+
issues.push(`${key}: a file conflicts with its parent directory.`);
|
|
4324
|
+
return;
|
|
4325
|
+
}
|
|
4326
|
+
}
|
|
4327
|
+
}
|
|
4328
|
+
if (!await this.reconcile("", issues)) return;
|
|
4329
|
+
const after = this.index.index().filter((row) => !this.excludes(row.path));
|
|
4330
|
+
const generations = new Map(before.map((row) => [row.path, row.generation]));
|
|
4331
|
+
if (after.length !== before.length || after.some((row) => row.eventPending || generations.get(row.path) !== row.generation))
|
|
4332
|
+
return;
|
|
4333
|
+
if (changes.length || !publication.complete) {
|
|
4334
|
+
this.publication.enqueue(this.root, {
|
|
4335
|
+
operationId: randomUUID7(),
|
|
4336
|
+
parentRevision: publication.revision,
|
|
4337
|
+
changes,
|
|
4338
|
+
...this.projectScope ? { configRevision: this.sharedConfig.revision } : {}
|
|
4339
|
+
});
|
|
4340
|
+
}
|
|
3303
4341
|
for (const snapshot of snapshots)
|
|
3304
4342
|
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
3305
4343
|
}
|
|
@@ -3342,7 +4380,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3342
4380
|
}
|
|
3343
4381
|
const job = this.index.job(owner);
|
|
3344
4382
|
const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
|
|
3345
|
-
await
|
|
4383
|
+
await mkdir12(ownerRoot).catch((error) => {
|
|
3346
4384
|
if (error.code !== "EEXIST") throw error;
|
|
3347
4385
|
});
|
|
3348
4386
|
const ownerStat = await lstat4(ownerRoot);
|
|
@@ -3397,8 +4435,9 @@ function message(error) {
|
|
|
3397
4435
|
}
|
|
3398
4436
|
|
|
3399
4437
|
// apps/client/sessions/synchronizer.ts
|
|
3400
|
-
import { randomUUID as
|
|
3401
|
-
import { join as
|
|
4438
|
+
import { randomUUID as randomUUID8 } from "node:crypto";
|
|
4439
|
+
import { join as join23 } from "node:path";
|
|
4440
|
+
import { performance as performance3 } from "node:perf_hooks";
|
|
3402
4441
|
|
|
3403
4442
|
// packages/protocol/session-key.ts
|
|
3404
4443
|
import { createHash as createHash10 } from "node:crypto";
|
|
@@ -3408,15 +4447,15 @@ function sessionKey(agent, nativeId) {
|
|
|
3408
4447
|
|
|
3409
4448
|
// apps/client/sessions/source.ts
|
|
3410
4449
|
import { createReadStream, realpathSync } from "node:fs";
|
|
3411
|
-
import { stat, realpath as
|
|
3412
|
-
import { basename as
|
|
4450
|
+
import { stat, realpath as realpath8, readdir as readdir2 } from "node:fs/promises";
|
|
4451
|
+
import { basename as basename4, dirname as dirname12, relative as relative5, isAbsolute as isAbsolute5, sep as sep6, join as join21 } from "node:path";
|
|
3413
4452
|
import { createInterface as createInterface3 } from "node:readline";
|
|
3414
4453
|
import * as zlib from "node:zlib";
|
|
3415
4454
|
|
|
3416
4455
|
// apps/client/sessions/cursor.ts
|
|
3417
4456
|
import { createHash as createHash11 } from "node:crypto";
|
|
3418
|
-
import { basename as
|
|
3419
|
-
import { DatabaseSync as
|
|
4457
|
+
import { basename as basename3, dirname as dirname11, resolve as resolve12 } from "node:path";
|
|
4458
|
+
import { DatabaseSync as DatabaseSync6 } from "node:sqlite";
|
|
3420
4459
|
import { fileURLToPath } from "node:url";
|
|
3421
4460
|
function fields(bytes) {
|
|
3422
4461
|
const data = Buffer.from(bytes);
|
|
@@ -3454,33 +4493,30 @@ function fields(bytes) {
|
|
|
3454
4493
|
return result2;
|
|
3455
4494
|
}
|
|
3456
4495
|
function readCursor(path, projectDir, checkCwd) {
|
|
3457
|
-
const db = new
|
|
4496
|
+
const db = new DatabaseSync6(path, { readOnly: true });
|
|
3458
4497
|
try {
|
|
3459
4498
|
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
|
|
3460
4499
|
if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
|
|
3461
4500
|
throw new Error("Unsupported Cursor CLI store version (expected 1).");
|
|
3462
|
-
const row = db.prepare("SELECT value FROM meta WHERE key='0'
|
|
4501
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='0'").get();
|
|
3463
4502
|
const hex = row?.value;
|
|
3464
4503
|
if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
|
|
3465
4504
|
throw new Error("Invalid Cursor metadata.");
|
|
3466
4505
|
const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
|
|
3467
|
-
if (typeof meta.agentId !== "string" || meta.agentId !==
|
|
4506
|
+
if (typeof meta.agentId !== "string" || meta.agentId !== basename3(dirname11(path)) || meta.subagentInfo != null)
|
|
3468
4507
|
throw new Error("Invalid or delegated Cursor session identity.");
|
|
3469
|
-
let budget = 64 * 1024 * 1024;
|
|
3470
4508
|
const blob = (id) => {
|
|
3471
4509
|
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
|
|
3472
|
-
const value = db.prepare("SELECT data FROM blobs WHERE id=?
|
|
3473
|
-
if (!(value instanceof Uint8Array))
|
|
3474
|
-
throw new Error("Cursor history missing or exceeds 64 MiB.");
|
|
4510
|
+
const value = db.prepare("SELECT data FROM blobs WHERE id=?").get(id)?.data;
|
|
4511
|
+
if (!(value instanceof Uint8Array)) throw new Error("Cursor history is missing.");
|
|
3475
4512
|
const bytes = Buffer.from(value);
|
|
3476
|
-
budget -= bytes.length;
|
|
3477
4513
|
if (createHash11("sha256").update(bytes).digest("hex") !== id)
|
|
3478
4514
|
throw new Error("Cursor blob failed integrity verification.");
|
|
3479
4515
|
return bytes;
|
|
3480
4516
|
};
|
|
3481
4517
|
const root = fields(blob(String(meta.latestRootBlobId)));
|
|
3482
|
-
const bucket =
|
|
3483
|
-
const md5 = (cwd2) => createHash11("md5").update(
|
|
4518
|
+
const bucket = basename3(dirname11(dirname11(path)));
|
|
4519
|
+
const md5 = (cwd2) => createHash11("md5").update(resolve12(cwd2)).digest("hex");
|
|
3484
4520
|
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
3485
4521
|
for (const uri of root.get(9) ?? []) {
|
|
3486
4522
|
try {
|
|
@@ -3496,9 +4532,8 @@ function readCursor(path, projectDir, checkCwd) {
|
|
|
3496
4532
|
checkCwd?.(cwd);
|
|
3497
4533
|
const refs = [];
|
|
3498
4534
|
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
if (refs.length > 5e4) throw new Error("Cursor history exceeds 50000 messages.");
|
|
4535
|
+
for (const ref of fields(blob(archive.toString("hex"))).get(1) ?? []) refs.push(ref);
|
|
4536
|
+
for (const ref of root.get(1) ?? []) refs.push(ref);
|
|
3502
4537
|
const messages = [];
|
|
3503
4538
|
const counts = /* @__PURE__ */ new Map();
|
|
3504
4539
|
for (const ref of refs) {
|
|
@@ -3541,11 +4576,11 @@ function readCursor(path, projectDir, checkCwd) {
|
|
|
3541
4576
|
|
|
3542
4577
|
// apps/client/sessions/source.ts
|
|
3543
4578
|
function within(project, cwd) {
|
|
3544
|
-
const rel =
|
|
3545
|
-
return rel !== ".." && !rel.startsWith(`..${
|
|
4579
|
+
const rel = relative5(project, cwd);
|
|
4580
|
+
return rel !== ".." && !rel.startsWith(`..${sep6}`) && !isAbsolute5(rel);
|
|
3546
4581
|
}
|
|
3547
4582
|
var cursorEncoded = (cwd) => cwd.replace(/^[/\\]+/, "").replace(/[/\\]/g, "-");
|
|
3548
|
-
var cursorDatabase = (path) =>
|
|
4583
|
+
var cursorDatabase = (path) => basename4(path) === "store.db";
|
|
3549
4584
|
async function sourceSignature(path, agent) {
|
|
3550
4585
|
const paths = agent === "cursor" && cursorDatabase(path) ? [path, `${path}-wal`] : [path];
|
|
3551
4586
|
const signatures = [];
|
|
@@ -3569,12 +4604,12 @@ async function sourceSignature(path, agent) {
|
|
|
3569
4604
|
return JSON.stringify(signatures);
|
|
3570
4605
|
}
|
|
3571
4606
|
async function cursorScope(path, project) {
|
|
3572
|
-
let container =
|
|
3573
|
-
while (
|
|
3574
|
-
container =
|
|
3575
|
-
if (
|
|
4607
|
+
let container = dirname12(path);
|
|
4608
|
+
while (basename4(container) !== "agent-transcripts" && dirname12(container) !== container)
|
|
4609
|
+
container = dirname12(container);
|
|
4610
|
+
if (basename4(container) !== "agent-transcripts")
|
|
3576
4611
|
throw new Error("Cursor transcript workspace cannot be verified.");
|
|
3577
|
-
const encoded =
|
|
4612
|
+
const encoded = basename4(dirname12(container));
|
|
3578
4613
|
const matches = [];
|
|
3579
4614
|
const visit = async (directory) => {
|
|
3580
4615
|
const prefix = cursorEncoded(directory);
|
|
@@ -3584,7 +4619,7 @@ async function cursorScope(path, project) {
|
|
|
3584
4619
|
}
|
|
3585
4620
|
if (!encoded.startsWith(prefix + "-")) return;
|
|
3586
4621
|
for (const entry of await readdir2(directory, { withFileTypes: true })) {
|
|
3587
|
-
if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(
|
|
4622
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(join21(directory, entry.name));
|
|
3588
4623
|
}
|
|
3589
4624
|
};
|
|
3590
4625
|
await visit(project);
|
|
@@ -3594,7 +4629,7 @@ async function cursorScope(path, project) {
|
|
|
3594
4629
|
}
|
|
3595
4630
|
async function readSource(agent, path, projectDir) {
|
|
3596
4631
|
const signature2 = await sourceSignature(path, agent);
|
|
3597
|
-
const project = await
|
|
4632
|
+
const project = await realpath8(projectDir);
|
|
3598
4633
|
if (agent === "cursor" && cursorDatabase(path)) {
|
|
3599
4634
|
const snapshot2 = readCursor(path, projectDir, (cwd) => {
|
|
3600
4635
|
if (!within(project, realpathSync(cwd)))
|
|
@@ -3602,7 +4637,7 @@ async function readSource(agent, path, projectDir) {
|
|
|
3602
4637
|
});
|
|
3603
4638
|
return { snapshot: snapshot2, incomplete: false, skipped: 0, signature: signature2 };
|
|
3604
4639
|
}
|
|
3605
|
-
const cursor = agent === "cursor" ? { nativeId:
|
|
4640
|
+
const cursor = agent === "cursor" ? { nativeId: basename4(path, ".jsonl"), cwd: await cursorScope(path, projectDir) } : void 0;
|
|
3606
4641
|
const normalizer = new JsonlNormalizer(agent, cursor);
|
|
3607
4642
|
const stream = createReadStream(path, {
|
|
3608
4643
|
end: Math.max(0, Number(JSON.parse(signature2)[0][2]) - 1)
|
|
@@ -3633,7 +4668,7 @@ async function readSource(agent, path, projectDir) {
|
|
|
3633
4668
|
}
|
|
3634
4669
|
const cwd = row.cwd ?? (row.type === "session_meta" ? object(row.payload).cwd : void 0);
|
|
3635
4670
|
if (typeof cwd === "string" && !checkedCwds.has(cwd)) {
|
|
3636
|
-
const actual = await
|
|
4671
|
+
const actual = await realpath8(cwd).catch((error) => {
|
|
3637
4672
|
if (error.code === "ENOENT" && !within(project, cwd))
|
|
3638
4673
|
throw new Error("Session cwd is outside the registered project.");
|
|
3639
4674
|
throw error;
|
|
@@ -3654,21 +4689,54 @@ async function readSource(agent, path, projectDir) {
|
|
|
3654
4689
|
if (before[0] !== after[0] || before[1] !== after[1] || Number(after[2]) < Number(before[2]) || before[2] === after[2] && before.join() !== after.join())
|
|
3655
4690
|
throw new Error("Session source changed while reading; retry next cycle.");
|
|
3656
4691
|
const snapshot = normalizer.finish();
|
|
3657
|
-
if (!within(project, await
|
|
4692
|
+
if (!within(project, await realpath8(snapshot.cwd)))
|
|
3658
4693
|
throw new Error("Session cwd is outside the registered project.");
|
|
3659
4694
|
return { snapshot, incomplete: lastBad, skipped, signature: signature2 };
|
|
3660
4695
|
}
|
|
3661
4696
|
|
|
3662
4697
|
// apps/client/sessions/discovery.ts
|
|
3663
4698
|
import { opendir as opendir2 } from "node:fs/promises";
|
|
3664
|
-
import {
|
|
4699
|
+
import { performance as performance2 } from "node:perf_hooks";
|
|
4700
|
+
import { join as join22 } from "node:path";
|
|
4701
|
+
|
|
4702
|
+
// apps/client/sessions/work-queue.ts
|
|
4703
|
+
var SessionWorkQueue = class {
|
|
4704
|
+
active = 0;
|
|
4705
|
+
waiting = [];
|
|
4706
|
+
async run(action) {
|
|
4707
|
+
if (this.active >= 2) await new Promise((resolve16) => this.waiting.push(resolve16));
|
|
4708
|
+
else this.active++;
|
|
4709
|
+
try {
|
|
4710
|
+
return await action();
|
|
4711
|
+
} finally {
|
|
4712
|
+
const next = this.waiting.shift();
|
|
4713
|
+
if (next) next();
|
|
4714
|
+
else this.active--;
|
|
4715
|
+
}
|
|
4716
|
+
}
|
|
4717
|
+
};
|
|
4718
|
+
var sessionWorkQueue = new SessionWorkQueue();
|
|
4719
|
+
var publications = /* @__PURE__ */ new Map();
|
|
4720
|
+
async function serializeSession(identity, action) {
|
|
4721
|
+
const previous = publications.get(identity) ?? Promise.resolve();
|
|
4722
|
+
const pending = previous.catch(() => {
|
|
4723
|
+
}).then(action);
|
|
4724
|
+
publications.set(identity, pending);
|
|
4725
|
+
try {
|
|
4726
|
+
return await pending;
|
|
4727
|
+
} finally {
|
|
4728
|
+
if (publications.get(identity) === pending) publications.delete(identity);
|
|
4729
|
+
}
|
|
4730
|
+
}
|
|
4731
|
+
|
|
4732
|
+
// apps/client/sessions/discovery.ts
|
|
3665
4733
|
async function* candidates(directory, agent) {
|
|
3666
4734
|
const queue = [directory];
|
|
3667
4735
|
while (queue.length) {
|
|
3668
4736
|
const current = queue.shift();
|
|
3669
4737
|
const dir = await opendir2(current);
|
|
3670
4738
|
for await (const entry of dir) {
|
|
3671
|
-
const path =
|
|
4739
|
+
const path = join22(current, entry.name);
|
|
3672
4740
|
if (entry.isDirectory()) queue.push(path);
|
|
3673
4741
|
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" || entry.name.endsWith(".jsonl") : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
3674
4742
|
yield path;
|
|
@@ -3682,10 +4750,12 @@ var SessionDiscovery = class {
|
|
|
3682
4750
|
}
|
|
3683
4751
|
state;
|
|
3684
4752
|
scans = /* @__PURE__ */ new Map();
|
|
4753
|
+
pending = false;
|
|
3685
4754
|
async cycle() {
|
|
4755
|
+
this.pending = false;
|
|
3686
4756
|
const issues = [];
|
|
3687
4757
|
let entries = 500;
|
|
3688
|
-
|
|
4758
|
+
const started = performance2.now();
|
|
3689
4759
|
const configs = this.state.directories();
|
|
3690
4760
|
for (const [directory, scan] of this.scans) {
|
|
3691
4761
|
if (!configs.some((config) => config.directory === directory)) {
|
|
@@ -3701,7 +4771,7 @@ var SessionDiscovery = class {
|
|
|
3701
4771
|
}
|
|
3702
4772
|
if (scan.nextAt > Date.now()) continue;
|
|
3703
4773
|
try {
|
|
3704
|
-
while (entries > 0 &&
|
|
4774
|
+
while (entries > 0 && performance2.now() - started < 1e3) {
|
|
3705
4775
|
entries--;
|
|
3706
4776
|
const entry = await scan.iterator.next();
|
|
3707
4777
|
if (entry.done) {
|
|
@@ -3714,13 +4784,14 @@ var SessionDiscovery = class {
|
|
|
3714
4784
|
const signature2 = await sourceSignature(path, config.agent);
|
|
3715
4785
|
const db = this.state.state.db;
|
|
3716
4786
|
if (db.prepare(
|
|
3717
|
-
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
3718
|
-
).get(this.state.root, path, signature2))
|
|
4787
|
+
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=? AND parser_version=?"
|
|
4788
|
+
).get(this.state.root, path, signature2, parserVersions[config.agent]))
|
|
3719
4789
|
continue;
|
|
3720
4790
|
try {
|
|
3721
|
-
const result2 = await
|
|
4791
|
+
const result2 = await sessionWorkQueue.run(
|
|
4792
|
+
() => readSource(config.agent, path, config.projectDir)
|
|
4793
|
+
);
|
|
3722
4794
|
if (!result2.snapshot.messages.length) continue;
|
|
3723
|
-
reads--;
|
|
3724
4795
|
this.state.track(config.agent, path, config.projectDir);
|
|
3725
4796
|
} catch (error) {
|
|
3726
4797
|
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
@@ -3730,8 +4801,8 @@ var SessionDiscovery = class {
|
|
|
3730
4801
|
continue;
|
|
3731
4802
|
}
|
|
3732
4803
|
db.prepare(
|
|
3733
|
-
"INSERT INTO session_discovery_cache VALUES (
|
|
3734
|
-
).run(this.state.root, path, signature2);
|
|
4804
|
+
"INSERT INTO session_discovery_cache(root,path,signature,parser_version) VALUES (?,?,?,?) ON CONFLICT(root,path) DO UPDATE SET signature=excluded.signature,parser_version=excluded.parser_version"
|
|
4805
|
+
).run(this.state.root, path, signature2, parserVersions[config.agent]);
|
|
3735
4806
|
}
|
|
3736
4807
|
} catch (error) {
|
|
3737
4808
|
if (error.code !== "ENOENT")
|
|
@@ -3743,6 +4814,7 @@ var SessionDiscovery = class {
|
|
|
3743
4814
|
scan.nextAt = Date.now() + 3e5;
|
|
3744
4815
|
}
|
|
3745
4816
|
}
|
|
4817
|
+
this.pending = [...this.scans.values()].some((scan) => scan.nextAt <= Date.now());
|
|
3746
4818
|
return issues;
|
|
3747
4819
|
}
|
|
3748
4820
|
async refresh() {
|
|
@@ -3758,10 +4830,11 @@ var SessionSynchronizer = class {
|
|
|
3758
4830
|
constructor(state, root, directory, signal) {
|
|
3759
4831
|
this.root = root;
|
|
3760
4832
|
this.directory = directory;
|
|
4833
|
+
this.signal = signal;
|
|
3761
4834
|
const binding = state.binding(root);
|
|
3762
4835
|
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
3763
4836
|
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
3764
|
-
this.blobs = new LocalBlobStore(
|
|
4837
|
+
this.blobs = new LocalBlobStore(join23(directory, "blobs"));
|
|
3765
4838
|
this.index = new SessionState(state, root);
|
|
3766
4839
|
this.discovery = new SessionDiscovery(this.index);
|
|
3767
4840
|
}
|
|
@@ -3772,8 +4845,16 @@ var SessionSynchronizer = class {
|
|
|
3772
4845
|
blobs;
|
|
3773
4846
|
discovery;
|
|
3774
4847
|
capable = false;
|
|
4848
|
+
passAt;
|
|
4849
|
+
signal;
|
|
3775
4850
|
async cycle() {
|
|
3776
|
-
const report = {
|
|
4851
|
+
const report = {
|
|
4852
|
+
published: 0,
|
|
4853
|
+
checked: 0,
|
|
4854
|
+
pending: false,
|
|
4855
|
+
issues: [],
|
|
4856
|
+
warnings: []
|
|
4857
|
+
};
|
|
3777
4858
|
report.issues.push(
|
|
3778
4859
|
...await consumeHints(
|
|
3779
4860
|
this.directory,
|
|
@@ -3783,7 +4864,10 @@ var SessionSynchronizer = class {
|
|
|
3783
4864
|
)
|
|
3784
4865
|
);
|
|
3785
4866
|
report.warnings.push(...await this.discovery.cycle());
|
|
3786
|
-
|
|
4867
|
+
this.passAt ??= Date.now();
|
|
4868
|
+
const candidates2 = this.index.sources(void 0, 201, this.passAt);
|
|
4869
|
+
const sources = candidates2.slice(0, 200);
|
|
4870
|
+
report.pending = candidates2.length > sources.length || this.discovery.pending;
|
|
3787
4871
|
if (sources.length && !this.capable) {
|
|
3788
4872
|
const info = await this.client.serverInfo();
|
|
3789
4873
|
if (!["blobs", "session-revisions"].every(
|
|
@@ -3792,60 +4876,86 @@ var SessionSynchronizer = class {
|
|
|
3792
4876
|
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
3793
4877
|
this.capable = true;
|
|
3794
4878
|
}
|
|
3795
|
-
let
|
|
3796
|
-
let
|
|
3797
|
-
|
|
3798
|
-
|
|
3799
|
-
|
|
3800
|
-
|
|
3801
|
-
|
|
3802
|
-
|
|
3803
|
-
|
|
3804
|
-
|
|
3805
|
-
|
|
3806
|
-
if (source.lastError)
|
|
3807
|
-
report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
|
|
3808
|
-
continue;
|
|
3809
|
-
}
|
|
3810
|
-
uploads--;
|
|
3811
|
-
await this.flush(source);
|
|
3812
|
-
report.published++;
|
|
3813
|
-
continue;
|
|
3814
|
-
}
|
|
3815
|
-
const signature2 = await sourceSignature(source.path, source.agent);
|
|
3816
|
-
if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
3817
|
-
this.index.checked(source.path);
|
|
3818
|
-
continue;
|
|
4879
|
+
let offset = 0;
|
|
4880
|
+
let handled = 0;
|
|
4881
|
+
const started = performance3.now();
|
|
4882
|
+
await Promise.all(
|
|
4883
|
+
Array.from({ length: 2 }, async () => {
|
|
4884
|
+
for (; ; ) {
|
|
4885
|
+
if (performance3.now() - started >= 1e3) break;
|
|
4886
|
+
const source = sources[offset++];
|
|
4887
|
+
if (!source || this.signal?.aborted) break;
|
|
4888
|
+
await sessionWorkQueue.run(() => this.collect(source.path, report));
|
|
4889
|
+
handled++;
|
|
3819
4890
|
}
|
|
3820
|
-
|
|
3821
|
-
|
|
3822
|
-
|
|
3823
|
-
|
|
3824
|
-
|
|
4891
|
+
})
|
|
4892
|
+
);
|
|
4893
|
+
report.pending ||= handled < candidates2.length;
|
|
4894
|
+
if (handled >= candidates2.length) this.passAt = void 0;
|
|
4895
|
+
return report;
|
|
4896
|
+
}
|
|
4897
|
+
lock(key, action) {
|
|
4898
|
+
return serializeSession(JSON.stringify([this.client.url, this.root, key]), action);
|
|
4899
|
+
}
|
|
4900
|
+
async collect(path, report) {
|
|
4901
|
+
if (this.signal?.aborted) return;
|
|
4902
|
+
let source = this.index.get(path);
|
|
4903
|
+
report.checked++;
|
|
4904
|
+
this.index.checked(source.path, source.lastError);
|
|
4905
|
+
try {
|
|
4906
|
+
if (source.pending) {
|
|
4907
|
+
if (source.pendingPath !== source.path) return;
|
|
4908
|
+
if (source.retryAt > Date.now()) {
|
|
4909
|
+
if (source.lastError)
|
|
4910
|
+
report.issues.push(`${source.path}: ${source.lastError} (retry deferred)`);
|
|
4911
|
+
return;
|
|
3825
4912
|
}
|
|
3826
|
-
|
|
4913
|
+
await this.lock(source.key, async () => {
|
|
4914
|
+
const current = this.index.get(source.path);
|
|
4915
|
+
if (!current.pending || current.pendingPath !== current.path || current.retryAt > Date.now())
|
|
4916
|
+
return;
|
|
4917
|
+
await this.flush(current);
|
|
4918
|
+
report.published++;
|
|
4919
|
+
});
|
|
4920
|
+
return;
|
|
4921
|
+
}
|
|
4922
|
+
const signature2 = await sourceSignature(source.path, source.agent);
|
|
4923
|
+
if (source.parserVersion === parserVersions[source.agent] && signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
4924
|
+
this.index.checked(source.path);
|
|
4925
|
+
return;
|
|
4926
|
+
}
|
|
4927
|
+
const observed = await readSource(source.agent, source.path, source.projectDir);
|
|
4928
|
+
if (!observed.snapshot.messages.length) {
|
|
4929
|
+
this.index.checked(source.path);
|
|
4930
|
+
return;
|
|
4931
|
+
}
|
|
4932
|
+
if (observed.skipped)
|
|
4933
|
+
report.warnings.push(
|
|
4934
|
+
`${source.path}: skipped ${observed.skipped} unreadable record(s); retained complete dialogue.`
|
|
4935
|
+
);
|
|
4936
|
+
if (observed.skipped && source.hash) {
|
|
4937
|
+
const previous = await this.blobs.get(source.hash);
|
|
4938
|
+
if (previous && JSON.parse(Buffer.from(previous).toString("utf8")).messages.length > observed.snapshot.messages.length) {
|
|
4939
|
+
this.index.observed(source.path, observed.signature);
|
|
3827
4940
|
report.warnings.push(
|
|
3828
|
-
`${source.path}:
|
|
4941
|
+
`${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
|
|
3829
4942
|
);
|
|
3830
|
-
|
|
3831
|
-
const previous = await this.blobs.get(source.hash);
|
|
3832
|
-
if (previous && JSON.parse(Buffer.from(previous).toString("utf8")).messages.length > observed.snapshot.messages.length) {
|
|
3833
|
-
this.index.observed(source.path, observed.signature);
|
|
3834
|
-
report.warnings.push(
|
|
3835
|
-
`${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
|
|
3836
|
-
);
|
|
3837
|
-
continue;
|
|
3838
|
-
}
|
|
4943
|
+
return;
|
|
3839
4944
|
}
|
|
3840
|
-
|
|
3841
|
-
|
|
3842
|
-
|
|
3843
|
-
|
|
3844
|
-
|
|
3845
|
-
|
|
3846
|
-
|
|
3847
|
-
|
|
3848
|
-
|
|
4945
|
+
}
|
|
4946
|
+
const key = sessionKey(source.agent, observed.snapshot.nativeId);
|
|
4947
|
+
if (source.key && source.key !== key)
|
|
4948
|
+
throw new Error(
|
|
4949
|
+
"Native session identity changed at this path; register the new source explicitly."
|
|
4950
|
+
);
|
|
4951
|
+
const bytes = Buffer.from(JSON.stringify(observed.snapshot));
|
|
4952
|
+
if (bytes.length > MAX_BLOB_BYTES)
|
|
4953
|
+
throw new Error(
|
|
4954
|
+
`Normalized session exceeds the ${MAX_BLOB_BYTES / 1024 / 1024} MiB snapshot limit.`
|
|
4955
|
+
);
|
|
4956
|
+
const blob = await this.blobs.put(bytes);
|
|
4957
|
+
await this.lock(key, async () => {
|
|
4958
|
+
source = this.index.get(source.path);
|
|
3849
4959
|
let revision = source.revision;
|
|
3850
4960
|
if (!source.key && !this.index.publication(key)) {
|
|
3851
4961
|
try {
|
|
@@ -3864,30 +4974,29 @@ var SessionSynchronizer = class {
|
|
|
3864
4974
|
this.index.baseline(source.path, key, revision);
|
|
3865
4975
|
source = this.index.get(source.path);
|
|
3866
4976
|
revision = source.revision;
|
|
3867
|
-
if (source.pending)
|
|
4977
|
+
if (source.pending) return;
|
|
3868
4978
|
if (source.agent === "cursor" && source.selectedPath && cursorDatabase(source.selectedPath) && !cursorDatabase(source.path)) {
|
|
3869
4979
|
this.index.observed(source.path, observed.signature);
|
|
3870
|
-
|
|
4980
|
+
return;
|
|
3871
4981
|
}
|
|
3872
4982
|
if (blob.sha256 === source.hash) {
|
|
3873
4983
|
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
3874
|
-
|
|
4984
|
+
return;
|
|
3875
4985
|
}
|
|
3876
4986
|
this.index.stage(
|
|
3877
4987
|
source.path,
|
|
3878
|
-
{ operationId:
|
|
4988
|
+
{ operationId: randomUUID8(), sessionKey: key, parentRevision: revision, blob },
|
|
3879
4989
|
observed.signature
|
|
3880
4990
|
);
|
|
3881
|
-
uploads--;
|
|
3882
4991
|
await this.flush(this.index.get(source.path));
|
|
3883
4992
|
report.published++;
|
|
3884
|
-
}
|
|
3885
|
-
|
|
3886
|
-
|
|
3887
|
-
|
|
3888
|
-
|
|
4993
|
+
});
|
|
4994
|
+
} catch (error) {
|
|
4995
|
+
if (this.signal?.aborted) return;
|
|
4996
|
+
const message2 = error instanceof Error ? error.message : "Session synchronization failed.";
|
|
4997
|
+
this.index.checked(source.path, message2);
|
|
4998
|
+
report.issues.push(`${source.path}: ${message2}`);
|
|
3889
4999
|
}
|
|
3890
|
-
return report;
|
|
3891
5000
|
}
|
|
3892
5001
|
async flush(source) {
|
|
3893
5002
|
const request = source.pending;
|
|
@@ -3909,9 +5018,11 @@ var SessionSynchronizer = class {
|
|
|
3909
5018
|
source.path,
|
|
3910
5019
|
result2.revision,
|
|
3911
5020
|
request.blob.sha256,
|
|
3912
|
-
source.pendingSignature
|
|
5021
|
+
source.pendingSignature,
|
|
5022
|
+
source.pendingParserVersion
|
|
3913
5023
|
);
|
|
3914
5024
|
} catch (error) {
|
|
5025
|
+
if (this.signal?.aborted) throw error;
|
|
3915
5026
|
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
3916
5027
|
this.index.postpone(
|
|
3917
5028
|
source,
|
|
@@ -4027,7 +5138,13 @@ var SyncJob = class {
|
|
|
4027
5138
|
pendingPublication: false,
|
|
4028
5139
|
issues: []
|
|
4029
5140
|
};
|
|
4030
|
-
let sessions = {
|
|
5141
|
+
let sessions = {
|
|
5142
|
+
published: 0,
|
|
5143
|
+
checked: 0,
|
|
5144
|
+
pending: false,
|
|
5145
|
+
issues: [],
|
|
5146
|
+
warnings: []
|
|
5147
|
+
};
|
|
4031
5148
|
try {
|
|
4032
5149
|
await verifyWorkspace(this.state, this.root);
|
|
4033
5150
|
if (!this.controlStarted) {
|
|
@@ -4089,11 +5206,20 @@ var SyncJob = class {
|
|
|
4089
5206
|
);
|
|
4090
5207
|
}
|
|
4091
5208
|
this.lastIssue = issueKey;
|
|
4092
|
-
|
|
5209
|
+
const uploadReady = [this.state, new FilePublicationState(this.state.db, "project")].some(
|
|
5210
|
+
(state) => {
|
|
5211
|
+
const pending = state.pending(this.root);
|
|
5212
|
+
return pending && pending.retryAt <= now;
|
|
5213
|
+
}
|
|
5214
|
+
);
|
|
5215
|
+
this.nextAt = now + (sessions.pending ? 1e3 : report.issues.length || this.control.enabled && (this.control.status.configError || this.control.status.reportError) ? Math.min(
|
|
5216
|
+
9e5,
|
|
5217
|
+
Math.max(this.intervalMs, 1e4) * 2 ** Math.min(this.issueCount - 1, 5)
|
|
5218
|
+
) : pendingDownloads ? 250 : uploadReady ? 1e3 : this.intervalMs);
|
|
4093
5219
|
this.runtime.save(this.root, {
|
|
4094
5220
|
state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
|
|
4095
5221
|
checkedAt,
|
|
4096
|
-
lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
5222
|
+
lastSuccessAt: !issue && !pendingDownloads && !report.pendingPublication ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
4097
5223
|
pendingFiles,
|
|
4098
5224
|
pendingDownloads,
|
|
4099
5225
|
issue: issue || null
|
|
@@ -4139,7 +5265,7 @@ Use teammy status or teammy logs for diagnostics.
|
|
|
4139
5265
|
`;
|
|
4140
5266
|
async function runSyncCommand(args, version) {
|
|
4141
5267
|
const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
|
|
4142
|
-
const { values } =
|
|
5268
|
+
const { values } = parseArgs4({
|
|
4143
5269
|
args: action ? args.slice(1) : args,
|
|
4144
5270
|
options: {
|
|
4145
5271
|
...locationOptions,
|
|
@@ -4179,16 +5305,29 @@ async function runSyncCommand(args, version) {
|
|
|
4179
5305
|
"Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
|
|
4180
5306
|
);
|
|
4181
5307
|
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
5308
|
+
await prepareClient(directory, version, {
|
|
5309
|
+
resume: action === "on",
|
|
5310
|
+
...action ? { intent: { root, enabled: action === "on" } } : {}
|
|
5311
|
+
});
|
|
4182
5312
|
const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
4183
5313
|
try {
|
|
4184
5314
|
if (!state.binding(root)) throw new Error("Connect this workspace first.");
|
|
4185
5315
|
if (action) {
|
|
4186
5316
|
if (action === "on") await verifyWorkspace(state, root);
|
|
4187
5317
|
runtime.enable(root, action === "on");
|
|
4188
|
-
if (action === "on")
|
|
4189
|
-
|
|
4190
|
-
|
|
4191
|
-
|
|
5318
|
+
if (action === "on") {
|
|
5319
|
+
const hooks = await setupSessions(state, root, directory);
|
|
5320
|
+
for (const issue of hooks.issues) log.write("sessions.setup.failed", root, issue);
|
|
5321
|
+
await wakeDaemon(directory, version);
|
|
5322
|
+
} else {
|
|
5323
|
+
try {
|
|
5324
|
+
if (await runningDaemon(directory))
|
|
5325
|
+
await daemonRequest(directory, { method: "wake" }, 45e3);
|
|
5326
|
+
} catch (error) {
|
|
5327
|
+
log.write("daemon.recovering", root, diagnostic(error));
|
|
5328
|
+
await stopDaemon(directory);
|
|
5329
|
+
if (runtime.roots().length) await wakeDaemon(directory, version);
|
|
5330
|
+
}
|
|
4192
5331
|
const status = runtime.status(root);
|
|
4193
5332
|
if (status) runtime.save(root, { ...status, state: "stopped" });
|
|
4194
5333
|
log.write("sync.off_requested", root);
|
|
@@ -4249,10 +5388,60 @@ async function runSyncCommand(args, version) {
|
|
|
4249
5388
|
}
|
|
4250
5389
|
}
|
|
4251
5390
|
|
|
5391
|
+
// apps/client/maintenance-command.ts
|
|
5392
|
+
import { parseArgs as parseArgs5 } from "node:util";
|
|
5393
|
+
import { homedir as homedir6 } from "node:os";
|
|
5394
|
+
import { join as join24, resolve as resolve13 } from "node:path";
|
|
5395
|
+
import { mkdir as mkdir13, realpath as realpath9 } from "node:fs/promises";
|
|
5396
|
+
async function runMaintenanceCommand(command, args, version) {
|
|
5397
|
+
const { values } = parseArgs5({
|
|
5398
|
+
args,
|
|
5399
|
+
options: {
|
|
5400
|
+
"state-dir": { type: "string", default: join24(homedir6(), ".teammy", "client") },
|
|
5401
|
+
stop: { type: "boolean" },
|
|
5402
|
+
json: { type: "boolean" },
|
|
5403
|
+
help: { type: "boolean", short: "h" }
|
|
5404
|
+
}
|
|
5405
|
+
});
|
|
5406
|
+
if (values.help) {
|
|
5407
|
+
process.stdout.write(
|
|
5408
|
+
`Usage: teammy ${command}${command === "repair" ? " [--stop]" : ""} [--state-dir DIR] [--json]
|
|
5409
|
+
|
|
5410
|
+
${command === "repair" ? "Repair this local client and preserve project on/off settings. --stop pauses the entire background service without opening the business database." : "Stop and unregister this local background service and remove its project hooks. Keeps workspaces, bindings and history; then run npm uninstall -g @teammy/cli."}
|
|
5411
|
+
`
|
|
5412
|
+
);
|
|
5413
|
+
return 0;
|
|
5414
|
+
}
|
|
5415
|
+
if (command === "uninstall" && values.stop) throw new Error("--stop is a repair option.");
|
|
5416
|
+
await mkdir13(resolve13(values["state-dir"]), { recursive: true, mode: 448 });
|
|
5417
|
+
const directory = await realpath9(values["state-dir"]);
|
|
5418
|
+
const report = await prepareClient(directory, version, {
|
|
5419
|
+
force: true,
|
|
5420
|
+
resume: true,
|
|
5421
|
+
stop: Boolean(values.stop),
|
|
5422
|
+
uninstall: command === "uninstall"
|
|
5423
|
+
});
|
|
5424
|
+
if (values.json) json(report);
|
|
5425
|
+
else {
|
|
5426
|
+
result(
|
|
5427
|
+
command === "uninstall" ? "\u672C\u673A\u63A5\u5165\u5DF2\u5378\u8F7D" : values.stop ? "\u540E\u53F0\u540C\u6B65\u5DF2\u6682\u505C" : "\u5BA2\u6237\u7AEF\u5DF2\u5C31\u7EEA",
|
|
5428
|
+
version
|
|
5429
|
+
);
|
|
5430
|
+
if (!values.stop && command !== "uninstall")
|
|
5431
|
+
result(`\u5DF2\u6062\u590D ${report.enabled} \u4E2A\u9879\u76EE\uFF1B${report.stopped} \u4E2A\u9879\u76EE\u4FDD\u6301\u505C\u6B62`);
|
|
5432
|
+
if (report.issues.length) result(`${report.issues.length} \u9879\u914D\u7F6E\u9700\u8981\u5904\u7406`, "teammy logs --all");
|
|
5433
|
+
if (command === "uninstall") result("\u5B89\u88C5\u5305\u53EF\u79FB\u9664", "npm uninstall -g @teammy/cli");
|
|
5434
|
+
}
|
|
5435
|
+
return report.issues.length ? 1 : 0;
|
|
5436
|
+
}
|
|
5437
|
+
|
|
4252
5438
|
// apps/client/runtime/daemon.ts
|
|
4253
5439
|
import { createServer } from "node:net";
|
|
4254
5440
|
import { chmod as chmod2, unlink as unlink4 } from "node:fs/promises";
|
|
5441
|
+
import { join as join25 } from "node:path";
|
|
5442
|
+
import { resolve as resolve14 } from "node:path";
|
|
4255
5443
|
async function runDaemon(directory, version) {
|
|
5444
|
+
await prepareClient(directory, version, { daemonStartup: true });
|
|
4256
5445
|
const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
4257
5446
|
const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
|
|
4258
5447
|
let closing = false, serial = Promise.resolve();
|
|
@@ -4317,7 +5506,15 @@ async function runDaemon(directory, version) {
|
|
|
4317
5506
|
void (async () => {
|
|
4318
5507
|
const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
|
|
4319
5508
|
if (closing) throw new Error("Background service is stopping.");
|
|
4320
|
-
if (input.method === "ping")
|
|
5509
|
+
if (input.method === "ping")
|
|
5510
|
+
return {
|
|
5511
|
+
pid: process.pid,
|
|
5512
|
+
version,
|
|
5513
|
+
protocol: CONTROL_PROTOCOL,
|
|
5514
|
+
executable: process.execPath,
|
|
5515
|
+
entry: resolve14(process.argv[1]),
|
|
5516
|
+
schema: clientSchemaVersion
|
|
5517
|
+
};
|
|
4321
5518
|
if (input.method === "wake") {
|
|
4322
5519
|
retry.clear();
|
|
4323
5520
|
await reconcile();
|
|
@@ -4355,8 +5552,8 @@ async function runDaemon(directory, version) {
|
|
|
4355
5552
|
let timer;
|
|
4356
5553
|
let resolveStop = () => {
|
|
4357
5554
|
};
|
|
4358
|
-
const stopped = new Promise((
|
|
4359
|
-
resolveStop =
|
|
5555
|
+
const stopped = new Promise((resolve16) => {
|
|
5556
|
+
resolveStop = resolve16;
|
|
4360
5557
|
});
|
|
4361
5558
|
const stop = () => {
|
|
4362
5559
|
closing = true;
|
|
@@ -4367,14 +5564,15 @@ async function runDaemon(directory, version) {
|
|
|
4367
5564
|
await unlink4(socketPath(directory)).catch((e) => {
|
|
4368
5565
|
if (e.code !== "ENOENT") throw e;
|
|
4369
5566
|
});
|
|
4370
|
-
await new Promise((
|
|
5567
|
+
await new Promise((resolve16, reject) => {
|
|
4371
5568
|
server.once("error", reject);
|
|
4372
5569
|
server.listen(socketPath(directory), () => {
|
|
4373
5570
|
server.off("error", reject);
|
|
4374
|
-
|
|
5571
|
+
resolve16();
|
|
4375
5572
|
});
|
|
4376
5573
|
});
|
|
4377
5574
|
await chmod2(socketPath(directory), 384);
|
|
5575
|
+
writePrivateJson(join25(directory, "runtime", "daemon.json"), { pid: process.pid, directory });
|
|
4378
5576
|
process.once("SIGINT", stop);
|
|
4379
5577
|
process.once("SIGTERM", stop);
|
|
4380
5578
|
log.write("daemon.started", void 0, `version=${version}`);
|
|
@@ -4394,9 +5592,11 @@ async function runDaemon(directory, version) {
|
|
|
4394
5592
|
await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
|
|
4395
5593
|
await Promise.allSettled(oneShots.values());
|
|
4396
5594
|
for (const socket of sockets) socket.destroy();
|
|
4397
|
-
await new Promise((
|
|
5595
|
+
await new Promise((resolve16) => server.close(() => resolve16()));
|
|
4398
5596
|
await unlink4(socketPath(directory)).catch(() => {
|
|
4399
5597
|
});
|
|
5598
|
+
await unlink4(join25(directory, "runtime", "daemon.json")).catch(() => {
|
|
5599
|
+
});
|
|
4400
5600
|
log.write("daemon.stopped");
|
|
4401
5601
|
state.close();
|
|
4402
5602
|
lock.close();
|
|
@@ -4413,10 +5613,12 @@ Usage:
|
|
|
4413
5613
|
teammy server-info --url URL Check server connection and protocol capabilities
|
|
4414
5614
|
teammy server-info --help Show connection options
|
|
4415
5615
|
teammy connect --url URL --project ID [--member ID | --token-file FILE] [--reconnect] [--root DIR] [--state-dir DIR]
|
|
5616
|
+
teammy repair [--stop] Repair or pause this local background client
|
|
5617
|
+
teammy uninstall Remove local service and hooks; keep user data
|
|
4416
5618
|
teammy status [--root DIR] [--state-dir DIR]
|
|
4417
5619
|
teammy sync on | off [--root DIR]
|
|
4418
5620
|
teammy logs [--root DIR] Show recent diagnostic events
|
|
4419
|
-
teammy session --help
|
|
5621
|
+
teammy session --help Collect, read and search the conversation library (\u5BF9\u8BDD\u5E93)
|
|
4420
5622
|
|
|
4421
5623
|
Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
|
|
4422
5624
|
connect starts background sync; enabled projects resume after login.
|
|
@@ -4448,12 +5650,12 @@ async function runCli(args, version) {
|
|
|
4448
5650
|
return 1;
|
|
4449
5651
|
}
|
|
4450
5652
|
if (command === "_daemon") {
|
|
4451
|
-
const { values } =
|
|
5653
|
+
const { values } = parseArgs6({
|
|
4452
5654
|
args: args.slice(1),
|
|
4453
5655
|
options: { "state-dir": { type: "string" } }
|
|
4454
5656
|
});
|
|
4455
5657
|
if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
|
|
4456
|
-
const directory =
|
|
5658
|
+
const directory = resolve15(values["state-dir"]);
|
|
4457
5659
|
try {
|
|
4458
5660
|
await runDaemon(directory, version);
|
|
4459
5661
|
return 0;
|
|
@@ -4462,9 +5664,17 @@ async function runCli(args, version) {
|
|
|
4462
5664
|
return 1;
|
|
4463
5665
|
}
|
|
4464
5666
|
}
|
|
5667
|
+
if (command === "repair" || command === "uninstall") {
|
|
5668
|
+
try {
|
|
5669
|
+
return await runMaintenanceCommand(command, args.slice(1), version);
|
|
5670
|
+
} catch (error) {
|
|
5671
|
+
process.stderr.write(diagnostic(error) + "\n");
|
|
5672
|
+
return 1;
|
|
5673
|
+
}
|
|
5674
|
+
}
|
|
4465
5675
|
if (command === "session") {
|
|
4466
5676
|
try {
|
|
4467
|
-
return await runSessionCommand(args.slice(1));
|
|
5677
|
+
return await runSessionCommand(args.slice(1), version);
|
|
4468
5678
|
} catch (error) {
|
|
4469
5679
|
if (args[1] === "hook") return 0;
|
|
4470
5680
|
process.stderr.write(
|
|
@@ -4497,7 +5707,7 @@ async function runCli(args, version) {
|
|
|
4497
5707
|
return 1;
|
|
4498
5708
|
}
|
|
4499
5709
|
try {
|
|
4500
|
-
const { values } =
|
|
5710
|
+
const { values } = parseArgs6({
|
|
4501
5711
|
args: args.slice(1),
|
|
4502
5712
|
options: {
|
|
4503
5713
|
help: { type: "boolean", short: "h" },
|