@teammy/cli 0.0.9 → 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 +2485 -916
- 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 } from "node:os";
|
|
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 (
|
|
@@ -1513,6 +1699,28 @@ var sessionClientMigration = String.raw`
|
|
|
1513
1699
|
root TEXT NOT NULL, path TEXT NOT NULL, signature TEXT NOT NULL, PRIMARY KEY(root, path)
|
|
1514
1700
|
);
|
|
1515
1701
|
`;
|
|
1702
|
+
var sessionSourcesMigration = String.raw`
|
|
1703
|
+
CREATE TABLE session_hook_receipts (root TEXT NOT NULL, agent TEXT NOT NULL, received_at INTEGER NOT NULL, PRIMARY KEY(root,agent));
|
|
1704
|
+
CREATE TABLE session_publications (
|
|
1705
|
+
root TEXT NOT NULL REFERENCES connections(root), session_key TEXT NOT NULL,
|
|
1706
|
+
revision INTEGER NOT NULL DEFAULT 0, hash TEXT, selected_path TEXT,
|
|
1707
|
+
pending_json TEXT, pending_signature TEXT, pending_path TEXT,
|
|
1708
|
+
attempts INTEGER NOT NULL DEFAULT 0, retry_at INTEGER NOT NULL DEFAULT 0,
|
|
1709
|
+
PRIMARY KEY(root,session_key)
|
|
1710
|
+
);
|
|
1711
|
+
INSERT INTO session_publications(root,session_key,revision,hash,selected_path,pending_json,pending_signature,pending_path,attempts,retry_at)
|
|
1712
|
+
SELECT root,session_key,revision,hash,path,pending_json,pending_signature,path,attempts,retry_at FROM session_sources WHERE session_key IS NOT NULL;
|
|
1713
|
+
ALTER TABLE session_sources RENAME TO session_sources_old;
|
|
1714
|
+
DROP INDEX session_source_scheduling;
|
|
1715
|
+
CREATE TABLE session_sources (
|
|
1716
|
+
root TEXT NOT NULL REFERENCES connections(root), path TEXT NOT NULL, agent TEXT NOT NULL, project_dir TEXT NOT NULL,
|
|
1717
|
+
session_key TEXT, signature TEXT, checked_at INTEGER NOT NULL DEFAULT 0, verified_at INTEGER NOT NULL DEFAULT 0,
|
|
1718
|
+
last_error TEXT, adopt_existing INTEGER NOT NULL DEFAULT 0, PRIMARY KEY(root,path)
|
|
1719
|
+
);
|
|
1720
|
+
INSERT INTO session_sources SELECT root,path,agent,project_dir,session_key,signature,checked_at,verified_at,last_error,adopt_existing FROM session_sources_old;
|
|
1721
|
+
DROP TABLE session_sources_old;
|
|
1722
|
+
CREATE INDEX session_source_scheduling ON session_sources(root,checked_at,path);
|
|
1723
|
+
`;
|
|
1516
1724
|
var SessionState = class {
|
|
1517
1725
|
constructor(state, root) {
|
|
1518
1726
|
this.state = state;
|
|
@@ -1532,25 +1740,31 @@ var SessionState = class {
|
|
|
1532
1740
|
get(path) {
|
|
1533
1741
|
return this.sources(path)[0];
|
|
1534
1742
|
}
|
|
1535
|
-
sources(path, limit = 2147483647) {
|
|
1743
|
+
sources(path, limit = 2147483647, before = Number.MAX_SAFE_INTEGER) {
|
|
1536
1744
|
return this.state.db.prepare(
|
|
1537
|
-
`SELECT
|
|
1538
|
-
|
|
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
|
|
1746
|
+
FROM session_sources s LEFT JOIN session_publications p USING(root,session_key)
|
|
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) => ({
|
|
1539
1749
|
path: String(row.path),
|
|
1540
1750
|
agent: row.agent,
|
|
1541
1751
|
projectDir: String(row.project_dir),
|
|
1542
1752
|
key: row.session_key,
|
|
1543
|
-
revision: Number(row.revision),
|
|
1544
|
-
hash: row.hash,
|
|
1753
|
+
revision: Number(row.revision ?? 0),
|
|
1754
|
+
hash: row.hash ?? null,
|
|
1545
1755
|
signature: row.signature,
|
|
1546
1756
|
checkedAt: Number(row.checked_at),
|
|
1547
1757
|
verifiedAt: Number(row.verified_at),
|
|
1758
|
+
parserVersion: Number(row.parser_version),
|
|
1759
|
+
pendingParserVersion: Number(row.pending_parser_version ?? 0),
|
|
1548
1760
|
lastError: row.last_error,
|
|
1549
1761
|
adoptExisting: Boolean(row.adopt_existing),
|
|
1550
1762
|
pending: row.pending_json ? SessionCommitSchema.parse(JSON.parse(String(row.pending_json))) : null,
|
|
1551
|
-
pendingSignature: row.pending_signature,
|
|
1552
|
-
|
|
1553
|
-
|
|
1763
|
+
pendingSignature: row.pending_signature ?? null,
|
|
1764
|
+
pendingPath: row.pending_path ?? null,
|
|
1765
|
+
selectedPath: row.selected_path ?? null,
|
|
1766
|
+
attempts: Number(row.attempts ?? 0),
|
|
1767
|
+
retryAt: Number(row.retry_at ?? 0)
|
|
1554
1768
|
}));
|
|
1555
1769
|
}
|
|
1556
1770
|
status() {
|
|
@@ -1560,38 +1774,84 @@ var SessionState = class {
|
|
|
1560
1774
|
}));
|
|
1561
1775
|
}
|
|
1562
1776
|
untrack(path) {
|
|
1563
|
-
this.state.db
|
|
1777
|
+
transaction(this.state.db, () => {
|
|
1778
|
+
const source = this.get(path);
|
|
1779
|
+
this.state.db.prepare("DELETE FROM session_sources WHERE root=? AND path=?").run(this.root, path);
|
|
1780
|
+
if (!source?.key) return;
|
|
1781
|
+
if (!this.state.db.prepare("SELECT 1 FROM session_sources WHERE root=? AND session_key=?").get(this.root, source.key)) {
|
|
1782
|
+
this.state.db.prepare("DELETE FROM session_publications WHERE root=? AND session_key=?").run(this.root, source.key);
|
|
1783
|
+
} else {
|
|
1784
|
+
this.state.db.prepare(
|
|
1785
|
+
"UPDATE session_publications SET selected_path=NULL WHERE root=? AND session_key=? AND selected_path=?"
|
|
1786
|
+
).run(this.root, source.key, path);
|
|
1787
|
+
this.state.db.prepare(
|
|
1788
|
+
"UPDATE session_publications SET pending_json=NULL,pending_signature=NULL,pending_path=NULL,attempts=0,retry_at=0 WHERE root=? AND session_key=? AND pending_path=?"
|
|
1789
|
+
).run(this.root, source.key, path);
|
|
1790
|
+
}
|
|
1791
|
+
});
|
|
1564
1792
|
}
|
|
1565
1793
|
checked(path, error = null) {
|
|
1566
|
-
this.state.db.prepare("UPDATE session_sources SET checked_at=?,
|
|
1794
|
+
this.state.db.prepare("UPDATE session_sources SET checked_at=?,last_error=? WHERE root=? AND path=?").run(Date.now(), error, this.root, path);
|
|
1567
1795
|
}
|
|
1568
1796
|
stage(path, request, signature2) {
|
|
1569
1797
|
this.state.db.prepare(
|
|
1570
|
-
|
|
1571
|
-
|
|
1798
|
+
`UPDATE session_publications SET pending_json=?,pending_signature=?,pending_path=?,pending_parser_version=?,attempts=0,retry_at=0
|
|
1799
|
+
WHERE root=? AND session_key=? AND pending_json IS NULL`
|
|
1800
|
+
).run(
|
|
1801
|
+
JSON.stringify(request),
|
|
1802
|
+
signature2,
|
|
1803
|
+
path,
|
|
1804
|
+
parserVersions[this.get(path).agent],
|
|
1805
|
+
this.root,
|
|
1806
|
+
request.sessionKey
|
|
1807
|
+
);
|
|
1808
|
+
}
|
|
1809
|
+
acknowledge(path, revision, hash2, signature2, parserVersion) {
|
|
1810
|
+
const source = this.get(path);
|
|
1811
|
+
this.state.db.prepare(
|
|
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=?`
|
|
1813
|
+
).run(revision, hash2, path, this.root, source.key);
|
|
1814
|
+
this.observed(path, signature2, parserVersion);
|
|
1572
1815
|
}
|
|
1573
|
-
|
|
1816
|
+
observed(path, signature2, parserVersion = parserVersions[this.get(path).agent]) {
|
|
1574
1817
|
this.state.db.prepare(
|
|
1575
|
-
|
|
1576
|
-
).run(
|
|
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);
|
|
1577
1820
|
}
|
|
1578
1821
|
baseline(path, key, revision) {
|
|
1579
|
-
this.state.db.prepare(
|
|
1822
|
+
this.state.db.prepare(
|
|
1823
|
+
"INSERT OR IGNORE INTO session_publications(root,session_key,revision) VALUES (?,?,?)"
|
|
1824
|
+
).run(this.root, key, revision);
|
|
1825
|
+
this.state.db.prepare("UPDATE session_sources SET session_key=? WHERE root=? AND path=?").run(key, this.root, path);
|
|
1826
|
+
}
|
|
1827
|
+
publication(key) {
|
|
1828
|
+
return this.state.db.prepare("SELECT 1 FROM session_publications WHERE root=? AND session_key=?").get(this.root, key);
|
|
1580
1829
|
}
|
|
1581
1830
|
retry() {
|
|
1582
|
-
this.state.db.prepare("UPDATE
|
|
1831
|
+
this.state.db.prepare("UPDATE session_publications SET retry_at=0 WHERE root=?").run(this.root);
|
|
1583
1832
|
}
|
|
1584
1833
|
postpone(source, error, blocked) {
|
|
1585
1834
|
const at = blocked ? Number.MAX_SAFE_INTEGER : Date.now() + Math.min(9e5, 6e4 * 2 ** Math.min(source.attempts, 4));
|
|
1586
1835
|
this.state.db.prepare(
|
|
1587
|
-
"UPDATE
|
|
1588
|
-
).run(at,
|
|
1836
|
+
"UPDATE session_publications SET retry_at=?,attempts=attempts+1 WHERE root=? AND session_key=?"
|
|
1837
|
+
).run(at, this.root, source.key);
|
|
1838
|
+
this.checked(source.path, error);
|
|
1589
1839
|
}
|
|
1590
1840
|
discover(agent, directory, projectDir) {
|
|
1841
|
+
const prior = this.directories().find((item) => item.directory === directory);
|
|
1842
|
+
if (prior?.agent === agent && prior.projectDir === projectDir) return;
|
|
1591
1843
|
this.state.db.prepare(
|
|
1592
1844
|
"INSERT INTO session_discovery VALUES (?,?,?,?) ON CONFLICT(root,directory) DO UPDATE SET agent=excluded.agent,project_dir=excluded.project_dir"
|
|
1593
1845
|
).run(this.root, directory, agent, projectDir);
|
|
1594
|
-
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 + "/");
|
|
1847
|
+
}
|
|
1848
|
+
receivedHook(agent) {
|
|
1849
|
+
this.state.db.prepare(
|
|
1850
|
+
"INSERT INTO session_hook_receipts VALUES (?,?,?) ON CONFLICT(root,agent) DO UPDATE SET received_at=excluded.received_at"
|
|
1851
|
+
).run(this.root, agent, Date.now());
|
|
1852
|
+
}
|
|
1853
|
+
hooks() {
|
|
1854
|
+
return this.state.db.prepare("SELECT agent,received_at FROM session_hook_receipts WHERE root=? ORDER BY agent").all(this.root);
|
|
1595
1855
|
}
|
|
1596
1856
|
directories() {
|
|
1597
1857
|
return this.state.db.prepare("SELECT * FROM session_discovery WHERE root=? ORDER BY directory").all(this.root).map((row) => ({
|
|
@@ -1604,32 +1864,75 @@ var SessionState = class {
|
|
|
1604
1864
|
|
|
1605
1865
|
// apps/client/state.ts
|
|
1606
1866
|
var LocalBindingSchema = BindingSchema.extend({
|
|
1607
|
-
root:
|
|
1608
|
-
serverUrl:
|
|
1609
|
-
token:
|
|
1867
|
+
root: z11.string(),
|
|
1868
|
+
serverUrl: z11.string(),
|
|
1869
|
+
token: z11.string()
|
|
1610
1870
|
});
|
|
1611
|
-
var
|
|
1612
|
-
|
|
1613
|
-
const db = openDatabase(join4(directory, "client.sqlite3"), [
|
|
1614
|
-
String.raw`
|
|
1871
|
+
var clientMigrations = [
|
|
1872
|
+
String.raw`
|
|
1615
1873
|
CREATE TABLE connections (root TEXT PRIMARY KEY, device_id TEXT NOT NULL, binding_json TEXT);
|
|
1616
1874
|
`,
|
|
1617
|
-
|
|
1875
|
+
String.raw`
|
|
1618
1876
|
CREATE TABLE publications (root TEXT PRIMARY KEY REFERENCES connections(root), revision INTEGER NOT NULL DEFAULT 0, initialized INTEGER NOT NULL DEFAULT 0);
|
|
1619
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));
|
|
1620
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);
|
|
1621
1879
|
`,
|
|
1622
|
-
|
|
1623
|
-
|
|
1624
|
-
|
|
1625
|
-
|
|
1626
|
-
|
|
1627
|
-
|
|
1628
|
-
|
|
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
|
+
}
|
|
1629
1927
|
super(db);
|
|
1928
|
+
this.access = access2;
|
|
1630
1929
|
}
|
|
1631
1930
|
close() {
|
|
1632
|
-
|
|
1931
|
+
try {
|
|
1932
|
+
this.db.close();
|
|
1933
|
+
} finally {
|
|
1934
|
+
this.access?.close();
|
|
1935
|
+
}
|
|
1633
1936
|
}
|
|
1634
1937
|
assertRootAvailable(root) {
|
|
1635
1938
|
for (const row of this.db.prepare("SELECT root FROM connections").all()) {
|
|
@@ -1645,7 +1948,7 @@ var ClientState = class extends FilePublicationState {
|
|
|
1645
1948
|
}
|
|
1646
1949
|
deviceFor(root) {
|
|
1647
1950
|
this.assertRootAvailable(root);
|
|
1648
|
-
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());
|
|
1649
1952
|
return String(
|
|
1650
1953
|
this.db.prepare("SELECT device_id FROM connections WHERE root=?").get(root).device_id
|
|
1651
1954
|
);
|
|
@@ -1687,11 +1990,14 @@ async function stateLocation(root, directory) {
|
|
|
1687
1990
|
return canonical;
|
|
1688
1991
|
}
|
|
1689
1992
|
|
|
1690
|
-
// apps/client/sessions/
|
|
1691
|
-
import {
|
|
1692
|
-
import {
|
|
1693
|
-
import {
|
|
1694
|
-
import {
|
|
1993
|
+
// apps/client/sessions/setup.ts
|
|
1994
|
+
import { execFile as execFile2 } from "node:child_process";
|
|
1995
|
+
import { promisify as promisify2 } from "node:util";
|
|
1996
|
+
import { access, mkdir as mkdir5, readFile as readFile2, writeFile, realpath as realpath4 } from "node:fs/promises";
|
|
1997
|
+
import { constants as constants2 } from "node:fs";
|
|
1998
|
+
import { homedir } from "node:os";
|
|
1999
|
+
import { dirname as dirname6, join as join10, resolve as resolve4 } from "node:path";
|
|
2000
|
+
import { createHash as createHash6 } from "node:crypto";
|
|
1695
2001
|
|
|
1696
2002
|
// apps/client/sessions/normalize.ts
|
|
1697
2003
|
function object(value) {
|
|
@@ -1702,91 +2008,118 @@ function textContent(value) {
|
|
|
1702
2008
|
if (!Array.isArray(value)) return "";
|
|
1703
2009
|
return value.flatMap((block) => {
|
|
1704
2010
|
const part = object(block);
|
|
1705
|
-
return ["text", "input_text", "output_text"].includes(String(part.type)) && typeof part.text === "string" ? [part.text] : [];
|
|
2011
|
+
return ["text", "Text", "input_text", "output_text"].includes(String(part.type)) && typeof part.text === "string" ? [part.text] : [];
|
|
1706
2012
|
}).join("\n");
|
|
1707
2013
|
}
|
|
1708
|
-
function
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
if (!line.trim()) continue;
|
|
1714
|
-
if (Buffer.byteLength(line) > 8 * 1024 * 1024) throw new Error("Session record exceeds 8 MiB.");
|
|
1715
|
-
let parsed;
|
|
1716
|
-
try {
|
|
1717
|
-
parsed = JSON.parse(line);
|
|
1718
|
-
} catch {
|
|
1719
|
-
if (index === lines.length - 1 && !text.endsWith("\n")) return { rows, incomplete: true };
|
|
1720
|
-
throw new Error(`Invalid session JSON at line ${index + 1}.`);
|
|
1721
|
-
}
|
|
1722
|
-
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
|
|
1723
|
-
throw new Error(`Invalid session record at line ${index + 1}.`);
|
|
1724
|
-
rows.push({ line: index + 1, value: object(parsed) });
|
|
2014
|
+
function parseRecord(line) {
|
|
2015
|
+
try {
|
|
2016
|
+
const value = JSON.parse(line);
|
|
2017
|
+
if (value && typeof value === "object" && !Array.isArray(value)) return object(value);
|
|
2018
|
+
} catch {
|
|
1725
2019
|
}
|
|
1726
|
-
return
|
|
2020
|
+
return void 0;
|
|
1727
2021
|
}
|
|
1728
|
-
|
|
1729
|
-
|
|
1730
|
-
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
2022
|
+
var JsonlNormalizer = class {
|
|
2023
|
+
constructor(agent, cursor) {
|
|
2024
|
+
this.agent = agent;
|
|
2025
|
+
if (agent === "cursor" && cursor) {
|
|
2026
|
+
this.nativeId = cursor.nativeId;
|
|
2027
|
+
this.cwd = cursor.cwd;
|
|
2028
|
+
}
|
|
2029
|
+
}
|
|
2030
|
+
agent;
|
|
2031
|
+
nativeId;
|
|
2032
|
+
cwd;
|
|
2033
|
+
turn = "legacy";
|
|
2034
|
+
piParents = /* @__PURE__ */ new Map();
|
|
2035
|
+
piLeaf = null;
|
|
2036
|
+
piTree = false;
|
|
2037
|
+
candidates = [];
|
|
2038
|
+
add(role, content, id, line, visible = false) {
|
|
1735
2039
|
if (role !== "user" && role !== "assistant") return;
|
|
1736
|
-
const
|
|
1737
|
-
if (
|
|
1738
|
-
|
|
1739
|
-
|
|
1740
|
-
|
|
1741
|
-
|
|
1742
|
-
|
|
1743
|
-
for (const { line, value: row } of rows) {
|
|
1744
|
-
if (agent === "claude-code") {
|
|
1745
|
-
if (row.isSidechain === true) continue;
|
|
2040
|
+
const text = textContent(content);
|
|
2041
|
+
if (text.trim())
|
|
2042
|
+
this.candidates.push({ role, text, id, line, group: `${this.turn}:${role}`, visible });
|
|
2043
|
+
}
|
|
2044
|
+
consume(row, line) {
|
|
2045
|
+
if (this.agent === "claude-code") {
|
|
2046
|
+
if (row.isSidechain === true) return;
|
|
1746
2047
|
if (row.sessionId) {
|
|
1747
|
-
if (nativeId && nativeId !== row.sessionId)
|
|
2048
|
+
if (this.nativeId && this.nativeId !== row.sessionId)
|
|
1748
2049
|
throw new Error("Mixed Claude session identities.");
|
|
1749
|
-
nativeId = row.sessionId;
|
|
2050
|
+
this.nativeId = row.sessionId;
|
|
1750
2051
|
}
|
|
1751
|
-
if (row.cwd && !cwd) cwd = row.cwd;
|
|
1752
|
-
if (row.type === "user" || row.type === "assistant") {
|
|
2052
|
+
if (row.cwd && !this.cwd) this.cwd = row.cwd;
|
|
2053
|
+
if ((row.type === "user" || row.type === "assistant") && row.isMeta !== true) {
|
|
1753
2054
|
const message2 = object(row.message);
|
|
1754
|
-
add(message2.role ?? row.type, message2.content, row.uuid, line);
|
|
2055
|
+
this.add(message2.role ?? row.type, message2.content, row.uuid, line);
|
|
1755
2056
|
}
|
|
1756
|
-
} else if (agent === "codex") {
|
|
2057
|
+
} else if (this.agent === "codex") {
|
|
1757
2058
|
const payload = object(row.payload);
|
|
1758
2059
|
if (row.type === "session_meta") {
|
|
1759
|
-
if (nativeId && nativeId !== payload.id)
|
|
1760
|
-
|
|
1761
|
-
|
|
2060
|
+
if (this.nativeId && this.nativeId !== payload.id)
|
|
2061
|
+
throw new Error("Mixed Codex session identities.");
|
|
2062
|
+
this.nativeId = payload.id;
|
|
2063
|
+
this.cwd = payload.cwd;
|
|
2064
|
+
}
|
|
2065
|
+
if (typeof payload.turn_id === "string") this.turn = payload.turn_id;
|
|
2066
|
+
if (row.type === "event_msg" && payload.type === "item_completed") {
|
|
2067
|
+
const item = object(payload.item);
|
|
2068
|
+
if (item.type === "UserMessage" || item.type === "AgentMessage")
|
|
2069
|
+
this.add(
|
|
2070
|
+
item.type === "UserMessage" ? "user" : "assistant",
|
|
2071
|
+
item.content,
|
|
2072
|
+
item.id,
|
|
2073
|
+
line,
|
|
2074
|
+
true
|
|
2075
|
+
);
|
|
2076
|
+
}
|
|
2077
|
+
if (row.type === "response_item" && payload.type === "message" && payload.channel !== "analysis") {
|
|
2078
|
+
const kinds = object(payload.internal_chat_message_metadata_passthrough).content_item_kinds;
|
|
2079
|
+
if (payload.role === "user" && Array.isArray(kinds) && !kinds.some((k) => String(k).startsWith("user.")))
|
|
2080
|
+
return;
|
|
2081
|
+
this.add(payload.role, payload.content, payload.id, line);
|
|
1762
2082
|
}
|
|
1763
|
-
|
|
1764
|
-
|
|
1765
|
-
|
|
2083
|
+
} else if (this.agent === "cursor") {
|
|
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);
|
|
2089
|
+
this.add(row.role ?? message2.role, message2.content ?? row.content, row.id ?? row.uuid, line);
|
|
2090
|
+
} else if (this.agent === "pi") {
|
|
1766
2091
|
if (row.type === "session") {
|
|
1767
2092
|
if (row.version !== 3) throw new Error("Unsupported Pi session version (expected 3).");
|
|
1768
|
-
if (nativeId) throw new Error("Multiple Pi session headers.");
|
|
1769
|
-
nativeId = row.id;
|
|
1770
|
-
cwd = row.cwd;
|
|
2093
|
+
if (this.nativeId) throw new Error("Multiple Pi session headers.");
|
|
2094
|
+
this.nativeId = row.id;
|
|
2095
|
+
this.cwd = row.cwd;
|
|
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;
|
|
1771
2104
|
}
|
|
1772
2105
|
if (row.type === "message") {
|
|
1773
2106
|
const message2 = object(row.message);
|
|
1774
|
-
add(message2.role, message2.content, row.id, line);
|
|
2107
|
+
this.add(message2.role, message2.content, row.id, line);
|
|
1775
2108
|
}
|
|
1776
2109
|
} else {
|
|
1777
2110
|
if (row.type === "session") {
|
|
1778
2111
|
if (row.version !== 0) throw new Error("Unsupported DSH session version (expected 0).");
|
|
1779
|
-
if (nativeId) throw new Error("Multiple DSH session headers.");
|
|
2112
|
+
if (this.nativeId) throw new Error("Multiple DSH session headers.");
|
|
1780
2113
|
if (Number(row.delegationDepth ?? 0) > 0)
|
|
1781
2114
|
throw new Error("DSH delegated sessions are not collected.");
|
|
1782
|
-
nativeId = row.id;
|
|
1783
|
-
cwd = row.cwd;
|
|
2115
|
+
this.nativeId = row.id;
|
|
2116
|
+
this.cwd = row.cwd;
|
|
1784
2117
|
}
|
|
1785
2118
|
if (row.type === "user/message" || row.type === "assistant/message") {
|
|
1786
2119
|
const data = object(row.data);
|
|
1787
2120
|
const message2 = row.type === "assistant/message" ? object(data.message) : data;
|
|
1788
|
-
if (object(message2.source).kind === "plugin")
|
|
1789
|
-
add(
|
|
2121
|
+
if (object(message2.source).kind === "plugin") return;
|
|
2122
|
+
this.add(
|
|
1790
2123
|
row.type === "user/message" ? "user" : "assistant",
|
|
1791
2124
|
message2.content,
|
|
1792
2125
|
`seq:${row.seq}`,
|
|
@@ -1795,103 +2128,1221 @@ function normalizeJsonl(agent, text) {
|
|
|
1795
2128
|
}
|
|
1796
2129
|
}
|
|
1797
2130
|
}
|
|
1798
|
-
|
|
1799
|
-
|
|
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
|
+
}
|
|
2141
|
+
const visibleGroups = new Set(this.candidates.filter((m) => m.visible).map((m) => m.group));
|
|
2142
|
+
const ids = /* @__PURE__ */ new Map();
|
|
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) => {
|
|
2151
|
+
const base = typeof m.id === "string" && m.id ? m.id : `line:${m.line}`;
|
|
2152
|
+
const count = (ids.get(base) ?? 0) + 1;
|
|
2153
|
+
ids.set(base, count);
|
|
2154
|
+
return { id: `${base}#${count}`, role: m.role, text: m.text, source: `line:${m.line}` };
|
|
2155
|
+
});
|
|
2156
|
+
return SessionSnapshotSchema.parse({
|
|
1800
2157
|
formatVersion: 1,
|
|
1801
2158
|
adapterVersion: 1,
|
|
1802
|
-
agent,
|
|
1803
|
-
nativeId,
|
|
1804
|
-
cwd,
|
|
2159
|
+
agent: this.agent,
|
|
2160
|
+
nativeId: this.nativeId,
|
|
2161
|
+
cwd: this.cwd,
|
|
1805
2162
|
messages
|
|
1806
|
-
})
|
|
1807
|
-
|
|
1808
|
-
|
|
1809
|
-
}
|
|
2163
|
+
});
|
|
2164
|
+
}
|
|
2165
|
+
};
|
|
1810
2166
|
|
|
1811
|
-
// apps/client/sessions/
|
|
1812
|
-
|
|
1813
|
-
|
|
1814
|
-
|
|
1815
|
-
|
|
1816
|
-
|
|
1817
|
-
|
|
1818
|
-
|
|
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;
|
|
1819
2178
|
}
|
|
1820
|
-
|
|
1821
|
-
|
|
1822
|
-
const targetDir = inbox(directory, root);
|
|
1823
|
-
await mkdir4(targetDir, { recursive: true, mode: 448 });
|
|
1824
|
-
const target = join5(
|
|
1825
|
-
targetDir,
|
|
1826
|
-
`${createHash5("sha256").update(JSON.stringify(hint)).digest("hex")}.json`
|
|
1827
|
-
);
|
|
1828
|
-
const temporary = `${target}.${randomUUID4()}.tmp`;
|
|
1829
|
-
const handle = await open2(temporary, "wx", 384);
|
|
1830
|
-
try {
|
|
1831
|
-
await handle.writeFile(JSON.stringify(hint));
|
|
1832
|
-
} finally {
|
|
1833
|
-
await handle.close();
|
|
1834
|
-
}
|
|
1835
|
-
await rename2(temporary, target);
|
|
2179
|
+
function saveRegistration(directory, value) {
|
|
2180
|
+
writePrivateJson(registrationPath(directory, value.root), value);
|
|
1836
2181
|
}
|
|
1837
|
-
|
|
1838
|
-
const
|
|
1839
|
-
|
|
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;
|
|
1840
2203
|
try {
|
|
1841
|
-
|
|
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;
|
|
1842
2211
|
} catch (error) {
|
|
1843
|
-
|
|
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.");
|
|
1844
2215
|
throw error;
|
|
1845
2216
|
}
|
|
1846
|
-
|
|
1847
|
-
|
|
1848
|
-
|
|
1849
|
-
|
|
1850
|
-
|
|
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")]) {
|
|
1851
2235
|
try {
|
|
1852
|
-
const
|
|
1853
|
-
|
|
1854
|
-
|
|
1855
|
-
if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
|
|
1856
|
-
body = await handle.readFile("utf8");
|
|
1857
|
-
} finally {
|
|
1858
|
-
await handle.close();
|
|
1859
|
-
}
|
|
1860
|
-
const hint = HintSchema.parse(JSON.parse(body));
|
|
1861
|
-
sessions.track(hint.agent, hint.path, hint.projectDir);
|
|
1862
|
-
await unlink2(path);
|
|
2236
|
+
const settings = JSON.parse(await readFile(path, "utf8"));
|
|
2237
|
+
if (typeof settings?.sessionDir === "string" && settings.sessionDir)
|
|
2238
|
+
configured = settings.sessionDir;
|
|
1863
2239
|
} catch (error) {
|
|
1864
|
-
|
|
1865
|
-
`hook hint ${entry.name}: ${error instanceof Error ? error.message : "invalid hint"}`
|
|
1866
|
-
);
|
|
2240
|
+
if (!missing(error)) throw new Error(`Unable to read Pi session directory setting: ${path}`);
|
|
1867
2241
|
}
|
|
1868
2242
|
}
|
|
1869
|
-
|
|
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
|
+
];
|
|
1870
2250
|
}
|
|
1871
|
-
|
|
1872
|
-
|
|
1873
|
-
|
|
1874
|
-
|
|
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;
|
|
1875
2293
|
try {
|
|
1876
|
-
|
|
1877
|
-
|
|
1878
|
-
|
|
1879
|
-
|
|
1880
|
-
|
|
1881
|
-
}
|
|
1882
|
-
|
|
1883
|
-
|
|
1884
|
-
|
|
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;
|
|
1885
2321
|
}
|
|
1886
2322
|
}
|
|
1887
2323
|
|
|
1888
|
-
// apps/client/sessions/
|
|
1889
|
-
var
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
2324
|
+
// apps/client/sessions/setup.ts
|
|
2325
|
+
var exec2 = promisify2(execFile2);
|
|
2326
|
+
var quote = (value) => "'" + value.replace(/'/g, "'\\''") + "'";
|
|
2327
|
+
async function exists(path, mode = constants2.F_OK) {
|
|
2328
|
+
try {
|
|
2329
|
+
await access(path, mode);
|
|
2330
|
+
return true;
|
|
2331
|
+
} catch {
|
|
2332
|
+
return false;
|
|
2333
|
+
}
|
|
2334
|
+
}
|
|
2335
|
+
async function setupSessions(state, root, directory, options = {}) {
|
|
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"));
|
|
2339
|
+
try {
|
|
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: []
|
|
2402
|
+
}
|
|
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);
|
|
2423
|
+
}
|
|
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
|
+
}
|
|
2448
|
+
}
|
|
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);
|
|
2454
|
+
try {
|
|
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
|
|
2509
|
+
});
|
|
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
|
+
}
|
|
2541
|
+
}
|
|
2542
|
+
} catch {
|
|
2543
|
+
}
|
|
2544
|
+
} catch (error) {
|
|
2545
|
+
issues.push(`${path}: ${error instanceof Error ? error.message : "Hook setup failed."}`);
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
return { configured, issues };
|
|
2549
|
+
} finally {
|
|
2550
|
+
lock.close();
|
|
2551
|
+
}
|
|
2552
|
+
}
|
|
2553
|
+
function hookMarker(directory, root) {
|
|
2554
|
+
return `teammy-session-${rootKey(directory)}-${rootKey(root)}`;
|
|
2555
|
+
}
|
|
2556
|
+
function setDiscoveryPreference(directory, root, source, enabled) {
|
|
2557
|
+
const lock = acquireRootLock("hooks", join10(directory, "runtime"));
|
|
2558
|
+
try {
|
|
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);
|
|
2566
|
+
} finally {
|
|
2567
|
+
lock.close();
|
|
2568
|
+
}
|
|
2569
|
+
}
|
|
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;
|
|
2597
|
+
try {
|
|
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();
|
|
2604
|
+
}
|
|
2605
|
+
for (const root of roots) {
|
|
2606
|
+
try {
|
|
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;
|
|
2753
|
+
try {
|
|
2754
|
+
if ((await handle.stat()).size > 16384) throw new Error("Oversized hook hint.");
|
|
2755
|
+
body = await handle.readFile("utf8");
|
|
2756
|
+
} finally {
|
|
2757
|
+
await handle.close();
|
|
2758
|
+
}
|
|
2759
|
+
const hint = HintSchema.parse(JSON.parse(body));
|
|
2760
|
+
sessions.receivedHook(hint.agent);
|
|
2761
|
+
if (hint.path) sessions.track(hint.agent, hint.path, hint.projectDir);
|
|
2762
|
+
if (hint.nativeId) await refresh?.();
|
|
2763
|
+
await unlink2(path);
|
|
2764
|
+
} catch (error) {
|
|
2765
|
+
issues.push(
|
|
2766
|
+
`hook hint ${entry.name}: ${error instanceof Error ? error.message : "invalid hint"}`
|
|
2767
|
+
);
|
|
2768
|
+
}
|
|
2769
|
+
}
|
|
2770
|
+
return issues;
|
|
2771
|
+
}
|
|
2772
|
+
async function hookPayload() {
|
|
2773
|
+
let size = 0;
|
|
2774
|
+
const parts = [];
|
|
2775
|
+
const timeout = setTimeout(() => process.stdin.destroy(new Error("Hook input timeout.")), 500);
|
|
2776
|
+
try {
|
|
2777
|
+
for await (const part of process.stdin) {
|
|
2778
|
+
const bytes = Buffer.from(part);
|
|
2779
|
+
size += bytes.length;
|
|
2780
|
+
if (size > 4 * 1024 * 1024) throw new Error("Hook payload too large.");
|
|
2781
|
+
parts.push(bytes);
|
|
2782
|
+
}
|
|
2783
|
+
return object(JSON.parse(Buffer.concat(parts).toString("utf8")));
|
|
2784
|
+
} finally {
|
|
2785
|
+
clearTimeout(timeout);
|
|
2786
|
+
}
|
|
2787
|
+
}
|
|
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
|
+
|
|
3335
|
+
// apps/client/sessions/command.ts
|
|
3336
|
+
var help = `Usage: teammy session COMMAND [options]
|
|
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)
|
|
3341
|
+
setup Configure project hooks and native discovery (also done by connect)
|
|
3342
|
+
track --agent AGENT --file PATH [--project-dir DIR] [--adopt-existing]
|
|
3343
|
+
untrack --file PATH (does not delete server history)
|
|
3344
|
+
discover --agent AGENT [--directory DIR] [--project-dir DIR]
|
|
3345
|
+
undiscover --directory DIR (also untrack any previously discovered sources to stop them)
|
|
1895
3346
|
sources Show registered sources, discovery directories and collection errors
|
|
1896
3347
|
hook --agent AGENT [--file PATH] [--project-dir DIR]; accepts JSON on stdin
|
|
1897
3348
|
list [--after N] [--limit N]
|
|
@@ -1904,17 +3355,21 @@ track/discover only register local sources. Run teammy sync to collect and publi
|
|
|
1904
3355
|
Discover uses native default locations except DSH, which requires --directory.
|
|
1905
3356
|
Hook only records a local hint, emits no stdout, and always exits successfully.
|
|
1906
3357
|
`;
|
|
1907
|
-
async function runSessionCommand(args) {
|
|
3358
|
+
async function runSessionCommand(args, version) {
|
|
1908
3359
|
const command = args[0];
|
|
1909
3360
|
if (!command || ["--help", "-h", "help"].includes(command)) {
|
|
1910
3361
|
process.stdout.write(help);
|
|
1911
3362
|
return 0;
|
|
1912
3363
|
}
|
|
1913
|
-
|
|
3364
|
+
if (command === "hook") {
|
|
3365
|
+
await runHook(args.slice(1));
|
|
3366
|
+
return 0;
|
|
3367
|
+
}
|
|
3368
|
+
const { values } = parseArgs2({
|
|
1914
3369
|
args: args.slice(1),
|
|
1915
3370
|
options: {
|
|
1916
|
-
root: { type: "string", default:
|
|
1917
|
-
"state-dir": { type: "string", default:
|
|
3371
|
+
root: { type: "string", default: resolve8("workspaces") },
|
|
3372
|
+
"state-dir": { type: "string", default: join16(homedir4(), ".teammy", "client") },
|
|
1918
3373
|
agent: { type: "string" },
|
|
1919
3374
|
file: { type: "string" },
|
|
1920
3375
|
directory: { type: "string" },
|
|
@@ -1926,27 +3381,25 @@ async function runSessionCommand(args) {
|
|
|
1926
3381
|
query: { type: "string" },
|
|
1927
3382
|
after: { type: "string", default: "0" },
|
|
1928
3383
|
limit: { type: "string", default: "50" },
|
|
1929
|
-
help: { type: "boolean", short: "h" }
|
|
3384
|
+
help: { type: "boolean", short: "h" },
|
|
3385
|
+
all: { type: "boolean" }
|
|
1930
3386
|
}
|
|
1931
3387
|
});
|
|
1932
3388
|
if (values.help) {
|
|
1933
3389
|
process.stdout.write(help);
|
|
1934
3390
|
return 0;
|
|
1935
3391
|
}
|
|
1936
|
-
|
|
1937
|
-
|
|
1938
|
-
|
|
1939
|
-
|
|
1940
|
-
|
|
1941
|
-
|
|
1942
|
-
if (typeof file === "string" && file)
|
|
1943
|
-
await recordHint(directory, root, {
|
|
1944
|
-
agent: AgentSchema.parse(values.agent),
|
|
1945
|
-
path: resolve3(file),
|
|
1946
|
-
projectDir
|
|
1947
|
-
});
|
|
1948
|
-
return 0;
|
|
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;
|
|
1949
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);
|
|
1950
3403
|
const state = new ClientState(directory);
|
|
1951
3404
|
try {
|
|
1952
3405
|
const binding = state.binding(root);
|
|
@@ -1954,9 +3407,10 @@ async function runSessionCommand(args) {
|
|
|
1954
3407
|
const sessions = new SessionState(state, root);
|
|
1955
3408
|
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
1956
3409
|
let output;
|
|
1957
|
-
if (command === "
|
|
3410
|
+
if (command === "setup") output = await setupSessions(state, root, directory, { enable: true });
|
|
3411
|
+
else if (command === "track" || command === "untrack") {
|
|
1958
3412
|
if (!values.file) throw new Error("--file is required.");
|
|
1959
|
-
const path =
|
|
3413
|
+
const path = resolve8(values.file);
|
|
1960
3414
|
if (command === "track")
|
|
1961
3415
|
sessions.track(
|
|
1962
3416
|
AgentSchema.parse(values.agent),
|
|
@@ -1969,27 +3423,38 @@ async function runSessionCommand(args) {
|
|
|
1969
3423
|
} else if (command === "discover" || command === "undiscover") {
|
|
1970
3424
|
if (command === "undiscover") {
|
|
1971
3425
|
if (!values.directory) throw new Error("--directory is required.");
|
|
1972
|
-
|
|
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);
|
|
1973
3429
|
} else {
|
|
1974
3430
|
const agent = AgentSchema.parse(values.agent);
|
|
1975
3431
|
const defaults = {
|
|
1976
|
-
"claude-code":
|
|
1977
|
-
|
|
1978
|
-
|
|
1979
|
-
|
|
3432
|
+
"claude-code": join16(
|
|
3433
|
+
process.env.CLAUDE_CONFIG_DIR ?? join16(homedir4(), ".claude"),
|
|
3434
|
+
"projects"
|
|
3435
|
+
),
|
|
3436
|
+
codex: join16(process.env.CODEX_HOME ?? join16(homedir4(), ".codex"), "sessions"),
|
|
3437
|
+
cursor: join16(
|
|
3438
|
+
process.env.CURSOR_CONFIG_DIR ?? join16(homedir4(), ".cursor"),
|
|
1980
3439
|
"chats",
|
|
1981
|
-
|
|
3440
|
+
createHash8("md5").update(projectDir).digest("hex")
|
|
1982
3441
|
),
|
|
1983
|
-
pi:
|
|
3442
|
+
pi: join16(process.env.PI_CODING_AGENT_DIR ?? join16(homedir4(), ".pi", "agent"), "sessions"),
|
|
1984
3443
|
dsh: void 0
|
|
1985
3444
|
};
|
|
1986
3445
|
const path = values.directory ?? defaults[agent];
|
|
1987
3446
|
if (!path) throw new Error("DSH requires its configured persistence --directory.");
|
|
1988
|
-
|
|
3447
|
+
const source = await realpath6(resolve8(path));
|
|
3448
|
+
setDiscoveryPreference(directory, root, source, true);
|
|
3449
|
+
sessions.discover(agent, source, projectDir);
|
|
1989
3450
|
}
|
|
1990
3451
|
output = sessions.directories();
|
|
1991
3452
|
} else if (command === "sources")
|
|
1992
|
-
output = {
|
|
3453
|
+
output = {
|
|
3454
|
+
sources: sessions.status(),
|
|
3455
|
+
discovery: sessions.directories(),
|
|
3456
|
+
hooks: sessions.hooks()
|
|
3457
|
+
};
|
|
1993
3458
|
else {
|
|
1994
3459
|
const after = Number(values.after);
|
|
1995
3460
|
const limit = Number(values.limit);
|
|
@@ -2020,54 +3485,33 @@ async function runSessionCommand(args) {
|
|
|
2020
3485
|
}
|
|
2021
3486
|
|
|
2022
3487
|
// apps/client/sync-command.ts
|
|
2023
|
-
import { parseArgs as
|
|
3488
|
+
import { parseArgs as parseArgs4 } from "node:util";
|
|
2024
3489
|
import { setTimeout as setTimeout4 } from "node:timers/promises";
|
|
2025
3490
|
|
|
2026
3491
|
// apps/client/client-command.ts
|
|
2027
|
-
import { parseArgs as
|
|
2028
|
-
import { join as
|
|
2029
|
-
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";
|
|
2030
3495
|
import { createInterface as createInterface2 } from "node:readline/promises";
|
|
2031
3496
|
|
|
2032
3497
|
// apps/client/connection.ts
|
|
2033
|
-
import { readFile, mkdir as
|
|
3498
|
+
import { readFile as readFile5, mkdir as mkdir10 } from "node:fs/promises";
|
|
2034
3499
|
import { createInterface } from "node:readline/promises";
|
|
2035
3500
|
|
|
2036
|
-
// apps/client/root-lock.ts
|
|
2037
|
-
import { createHash as createHash7 } from "node:crypto";
|
|
2038
|
-
import { join as join7 } from "node:path";
|
|
2039
|
-
function acquireRootLock(root, stateDirectory) {
|
|
2040
|
-
let lock;
|
|
2041
|
-
try {
|
|
2042
|
-
lock = openDatabase(
|
|
2043
|
-
join7(stateDirectory, "locks", `${createHash7("sha256").update(root).digest("hex")}.sqlite3`),
|
|
2044
|
-
[],
|
|
2045
|
-
0
|
|
2046
|
-
);
|
|
2047
|
-
lock.exec("PRAGMA busy_timeout=0; BEGIN IMMEDIATE");
|
|
2048
|
-
return lock;
|
|
2049
|
-
} catch (error) {
|
|
2050
|
-
lock?.close();
|
|
2051
|
-
if (error instanceof Error && /locked|busy/.test(error.message))
|
|
2052
|
-
throw new Error("Another sync or connect process is using this root.");
|
|
2053
|
-
throw error;
|
|
2054
|
-
}
|
|
2055
|
-
}
|
|
2056
|
-
|
|
2057
3501
|
// apps/client/local-config.ts
|
|
2058
|
-
import { constants as
|
|
2059
|
-
import { open as open3, writeFile } from "node:fs/promises";
|
|
2060
|
-
import { join as
|
|
2061
|
-
import { z as
|
|
3502
|
+
import { constants as constants3 } from "node:fs";
|
|
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";
|
|
2062
3506
|
import ignore from "ignore";
|
|
2063
3507
|
var LOCAL_CONFIG_NAME = ".teammy-local.json";
|
|
2064
|
-
var LocalConfigSchema =
|
|
2065
|
-
files:
|
|
3508
|
+
var LocalConfigSchema = z14.object({
|
|
3509
|
+
files: z14.object({ ignore: z14.array(z14.string().max(1024)).max(256).default([]) }).strict().default({ ignore: [] })
|
|
2066
3510
|
}).strict();
|
|
2067
3511
|
async function ensureLocalConfig(root) {
|
|
2068
3512
|
try {
|
|
2069
|
-
await
|
|
2070
|
-
|
|
3513
|
+
await writeFile3(
|
|
3514
|
+
join17(root, LOCAL_CONFIG_NAME),
|
|
2071
3515
|
JSON.stringify({ files: { ignore: [] } }, null, 2) + "\n",
|
|
2072
3516
|
{ flag: "wx", mode: 384 }
|
|
2073
3517
|
);
|
|
@@ -2079,8 +3523,8 @@ async function loadLocalRules(root) {
|
|
|
2079
3523
|
let rules = [];
|
|
2080
3524
|
try {
|
|
2081
3525
|
const file = await open3(
|
|
2082
|
-
|
|
2083
|
-
|
|
3526
|
+
join17(root, LOCAL_CONFIG_NAME),
|
|
3527
|
+
constants3.O_RDONLY | constants3.O_NOFOLLOW | constants3.O_NONBLOCK
|
|
2084
3528
|
);
|
|
2085
3529
|
try {
|
|
2086
3530
|
const before = await file.stat();
|
|
@@ -2125,6 +3569,10 @@ async function connectProject(state, root, directory, options) {
|
|
|
2125
3569
|
if (existing && options.project && options.project !== existing.workspaceId)
|
|
2126
3570
|
throw new Error("This root belongs to a different project. Use a different local root.");
|
|
2127
3571
|
const info = await transport.serverInfo();
|
|
3572
|
+
if (!info.capabilities.includes("atomic-file-publications"))
|
|
3573
|
+
throw new Error(
|
|
3574
|
+
"Update the server before connecting: complete directory publications are required."
|
|
3575
|
+
);
|
|
2128
3576
|
if (!info.capabilities.includes("multi-project"))
|
|
2129
3577
|
throw new Error(
|
|
2130
3578
|
"This client requires a multi-project server. Update the server before connecting."
|
|
@@ -2136,7 +3584,7 @@ async function connectProject(state, root, directory, options) {
|
|
|
2136
3584
|
const client2 = new HubClient(existing.serverUrl, 1e4, existing);
|
|
2137
3585
|
await client2.members();
|
|
2138
3586
|
} else if (options.tokenFile) {
|
|
2139
|
-
const token = CredentialSchema.parse((await
|
|
3587
|
+
const token = CredentialSchema.parse((await readFile5(options.tokenFile, "utf8")).trim());
|
|
2140
3588
|
const deviceId = state.deviceFor(root);
|
|
2141
3589
|
const client2 = new HubClient(options.url, 1e4, { token, deviceId });
|
|
2142
3590
|
const binding2 = await client2.bind(deviceId, options.project ?? existing?.workspaceId);
|
|
@@ -2148,256 +3596,50 @@ async function connectProject(state, root, directory, options) {
|
|
|
2148
3596
|
if (!member && process.stdin.isTTY) {
|
|
2149
3597
|
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
2150
3598
|
try {
|
|
2151
|
-
member = (await rl.question("Member ID: ")).trim();
|
|
2152
|
-
} finally {
|
|
2153
|
-
rl.close();
|
|
2154
|
-
}
|
|
2155
|
-
}
|
|
2156
|
-
if (!member) throw new Error("--member is required when input is not interactive.");
|
|
2157
|
-
NameSchema.parse(member);
|
|
2158
|
-
if (existing && existing.memberId !== member)
|
|
2159
|
-
throw new Error("This root belongs to a different member.");
|
|
2160
|
-
const projectId = options.project ?? existing?.workspaceId;
|
|
2161
|
-
if (!projectId)
|
|
2162
|
-
throw new Error(
|
|
2163
|
-
"--project is required for a new connection; copy the command from the project settings."
|
|
2164
|
-
);
|
|
2165
|
-
NameSchema.parse(projectId);
|
|
2166
|
-
const request = project.enrollment(
|
|
2167
|
-
transport.url,
|
|
2168
|
-
member,
|
|
2169
|
-
projectId,
|
|
2170
|
-
Boolean(options.reconnect)
|
|
2171
|
-
);
|
|
2172
|
-
const binding2 = await transport.enroll(request);
|
|
2173
|
-
state.saveBinding(root, transport.url, request.credential, binding2);
|
|
2174
|
-
project.completeEnrollment();
|
|
2175
|
-
}
|
|
2176
|
-
const binding = state.binding(root);
|
|
2177
|
-
const client = new HubClient(binding.serverUrl, 1e4, binding);
|
|
2178
|
-
if (info.capabilities.includes("project-config")) {
|
|
2179
|
-
try {
|
|
2180
|
-
await applyProjectFile(project, await client.projectConfig());
|
|
2181
|
-
} catch (error) {
|
|
2182
|
-
if (!(options.tokenFile && error instanceof Error && "code" in error && ["SETUP_REQUIRED", "PROJECT_NOT_FOUND"].includes(String(error.code))))
|
|
2183
|
-
throw error;
|
|
2184
|
-
}
|
|
2185
|
-
}
|
|
2186
|
-
const own = await metadata(root, binding.memberId);
|
|
2187
|
-
if (own && !own.directory) throw new Error("Member path is not a directory.");
|
|
2188
|
-
if (!own) await mkdir5(await safePath(root, binding.memberId));
|
|
2189
|
-
await ensureLocalConfig(root);
|
|
2190
|
-
const { token: _, ...visible } = binding;
|
|
2191
|
-
return { ...visible, configRevision: project.cached()?.config.configRevision ?? null };
|
|
2192
|
-
} finally {
|
|
2193
|
-
lock.close();
|
|
2194
|
-
}
|
|
2195
|
-
}
|
|
2196
|
-
|
|
2197
|
-
// apps/client/runtime/ipc.ts
|
|
2198
|
-
import { createConnection } from "node:net";
|
|
2199
|
-
import { mkdir as mkdir6, lstat as lstat3, chmod } from "node:fs/promises";
|
|
2200
|
-
import { join as join9 } from "node:path";
|
|
2201
|
-
import { z as z12 } from "zod";
|
|
2202
|
-
var RequestSchema = z12.object({
|
|
2203
|
-
method: z12.enum(["ping", "wake", "status", "once"]),
|
|
2204
|
-
root: z12.string().max(4096).optional()
|
|
2205
|
-
}).strict();
|
|
2206
|
-
function socketPath(directory) {
|
|
2207
|
-
return join9(
|
|
2208
|
-
"/tmp",
|
|
2209
|
-
`teammy-${process.getuid?.() ?? "user"}-${rootKey(directory)}`,
|
|
2210
|
-
"control.sock"
|
|
2211
|
-
);
|
|
2212
|
-
}
|
|
2213
|
-
async function prepareSocketDirectory(directory) {
|
|
2214
|
-
const path = join9(socketPath(directory), "..");
|
|
2215
|
-
await mkdir6(path, { recursive: true, mode: 448 });
|
|
2216
|
-
const stat2 = await lstat3(path);
|
|
2217
|
-
if (!stat2.isDirectory() || stat2.isSymbolicLink() || stat2.uid !== process.getuid?.())
|
|
2218
|
-
throw new Error("Unsafe daemon socket directory.");
|
|
2219
|
-
await chmod(path, 448);
|
|
2220
|
-
}
|
|
2221
|
-
async function daemonRequest(directory, input, timeout = 15e3) {
|
|
2222
|
-
RequestSchema.parse(input);
|
|
2223
|
-
return new Promise((resolve9, reject) => {
|
|
2224
|
-
const socket = createConnection(socketPath(directory));
|
|
2225
|
-
socket.setEncoding("utf8");
|
|
2226
|
-
let body = "";
|
|
2227
|
-
let done = false;
|
|
2228
|
-
const finish = (error, result2) => {
|
|
2229
|
-
if (done) return;
|
|
2230
|
-
done = true;
|
|
2231
|
-
socket.destroy();
|
|
2232
|
-
if (error) reject(error);
|
|
2233
|
-
else resolve9(result2);
|
|
2234
|
-
};
|
|
2235
|
-
socket.setTimeout(
|
|
2236
|
-
timeout,
|
|
2237
|
-
() => finish(new Error("Background service did not respond. Run teammy status."))
|
|
2238
|
-
);
|
|
2239
|
-
socket.on("error", (e) => finish(e));
|
|
2240
|
-
socket.on("end", () => {
|
|
2241
|
-
if (!done)
|
|
2242
|
-
finish(
|
|
2243
|
-
Object.assign(new Error("Background service disconnected."), { code: "ECONNRESET" })
|
|
2244
|
-
);
|
|
2245
|
-
});
|
|
2246
|
-
socket.on("connect", () => socket.write(JSON.stringify(input) + "\n"));
|
|
2247
|
-
socket.on("data", (chunk) => {
|
|
2248
|
-
body += chunk.toString();
|
|
2249
|
-
if (body.length > 256 * 1024) return finish(new Error("Oversized daemon response."));
|
|
2250
|
-
if (!body.includes("\n")) return;
|
|
2251
|
-
try {
|
|
2252
|
-
const response = JSON.parse(body.split("\n")[0]);
|
|
2253
|
-
if (response.ok) finish(void 0, response.result);
|
|
2254
|
-
else finish(new Error(response.error ?? "Background operation failed."));
|
|
2255
|
-
} catch (e) {
|
|
2256
|
-
finish(e);
|
|
2257
|
-
}
|
|
2258
|
-
});
|
|
2259
|
-
});
|
|
2260
|
-
}
|
|
2261
|
-
async function runningDaemon(directory) {
|
|
2262
|
-
try {
|
|
2263
|
-
return await daemonRequest(directory, { method: "ping" }, 1e3);
|
|
2264
|
-
} catch (error) {
|
|
2265
|
-
if (["ENOENT", "ECONNREFUSED", "ECONNRESET", "EPIPE"].includes(
|
|
2266
|
-
String(error.code)
|
|
2267
|
-
))
|
|
2268
|
-
return null;
|
|
2269
|
-
throw error;
|
|
2270
|
-
}
|
|
2271
|
-
}
|
|
2272
|
-
|
|
2273
|
-
// apps/client/runtime/service.ts
|
|
2274
|
-
import { execFile as execFile2 } from "node:child_process";
|
|
2275
|
-
import { promisify as promisify2 } from "node:util";
|
|
2276
|
-
import { homedir as homedir2 } from "node:os";
|
|
2277
|
-
import { dirname as dirname5, join as join10, resolve as resolve4 } from "node:path";
|
|
2278
|
-
import { mkdir as mkdir7, readFile as readFile2, writeFile as writeFile2, rename as rename3 } from "node:fs/promises";
|
|
2279
|
-
import { setTimeout as setTimeout3 } from "node:timers/promises";
|
|
2280
|
-
var execute = promisify2(execFile2);
|
|
2281
|
-
var exec2 = (file, args, options = {}) => execute(file, args, { timeout: 45e3, ...options });
|
|
2282
|
-
var xml = (s) => s.replace(
|
|
2283
|
-
/[<>&"']/g,
|
|
2284
|
-
(c) => ({ "<": "<", ">": ">", "&": "&", '"': """, "'": "'" })[c]
|
|
2285
|
-
);
|
|
2286
|
-
var unit = (s) => '"' + s.replace(/%/g, "%%").replace(/\\/g, "\\\\").replace(/"/g, '\\"').replace(/\$/g, () => "$$") + '"';
|
|
2287
|
-
function serviceDefinition(directory, executable, entry, platform = process.platform, userHome = homedir2()) {
|
|
2288
|
-
for (const value of [directory, executable, entry])
|
|
2289
|
-
if (/[\r\n\0]/.test(value)) throw new Error("Invalid service path.");
|
|
2290
|
-
const id = `com.teammy.client.${rootKey(directory)}`;
|
|
2291
|
-
const args = [executable, entry, "_daemon", "--state-dir", directory];
|
|
2292
|
-
if (platform === "darwin")
|
|
2293
|
-
return {
|
|
2294
|
-
id,
|
|
2295
|
-
path: join10(userHome, "Library", "LaunchAgents", id + ".plist"),
|
|
2296
|
-
content: `<?xml version="1.0" encoding="UTF-8"?>
|
|
2297
|
-
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2298
|
-
<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>
|
|
2299
|
-
`
|
|
2300
|
-
};
|
|
2301
|
-
if (platform === "linux")
|
|
2302
|
-
return {
|
|
2303
|
-
id: id + ".service",
|
|
2304
|
-
path: join10(userHome, ".config", "systemd", "user", id + ".service"),
|
|
2305
|
-
content: `[Unit]
|
|
2306
|
-
Description=Teammy background synchronization
|
|
2307
|
-
[Service]
|
|
2308
|
-
Type=simple
|
|
2309
|
-
ExecStart=${args.map(unit).join(" ")}
|
|
2310
|
-
Restart=always
|
|
2311
|
-
RestartSec=30
|
|
2312
|
-
UMask=0077
|
|
2313
|
-
StandardOutput=null
|
|
2314
|
-
StandardError=journal
|
|
2315
|
-
[Install]
|
|
2316
|
-
WantedBy=default.target
|
|
2317
|
-
`
|
|
2318
|
-
};
|
|
2319
|
-
throw new Error(
|
|
2320
|
-
"Automatic background sync currently supports macOS and Linux. Use --no-start with sync --foreground on other systems."
|
|
2321
|
-
);
|
|
2322
|
-
}
|
|
2323
|
-
async function checkServiceSupport() {
|
|
2324
|
-
try {
|
|
2325
|
-
if (process.platform === "darwin")
|
|
2326
|
-
await exec2("launchctl", ["print", `gui/${process.getuid()}`], {
|
|
2327
|
-
maxBuffer: 4 * 1024 * 1024
|
|
2328
|
-
});
|
|
2329
|
-
else if (process.platform === "linux") await exec2("systemctl", ["--user", "show-environment"]);
|
|
2330
|
-
else throw new Error("unsupported");
|
|
2331
|
-
} catch {
|
|
2332
|
-
throw new Error(
|
|
2333
|
-
"A login-session service manager is required (macOS launchd or Linux systemd --user). Use --no-start and sync --foreground where unavailable."
|
|
2334
|
-
);
|
|
2335
|
-
}
|
|
2336
|
-
}
|
|
2337
|
-
async function bootstrapLaunchAgent(path) {
|
|
2338
|
-
const deadline = Date.now() + 1e4;
|
|
2339
|
-
for (; ; ) {
|
|
2340
|
-
try {
|
|
2341
|
-
await exec2("launchctl", ["bootstrap", `gui/${process.getuid()}`, path]);
|
|
2342
|
-
return;
|
|
2343
|
-
} catch (error) {
|
|
2344
|
-
if (error.code !== 5 || Date.now() >= deadline) throw error;
|
|
2345
|
-
await setTimeout3(250);
|
|
2346
|
-
}
|
|
2347
|
-
}
|
|
2348
|
-
}
|
|
2349
|
-
async function ensureDaemon(directory, version) {
|
|
2350
|
-
const definition = serviceDefinition(directory, process.execPath, resolve4(process.argv[1]));
|
|
2351
|
-
let old = "";
|
|
2352
|
-
try {
|
|
2353
|
-
old = await readFile2(definition.path, "utf8");
|
|
2354
|
-
} catch (e) {
|
|
2355
|
-
if (e.code !== "ENOENT") throw e;
|
|
2356
|
-
}
|
|
2357
|
-
const alive = await runningDaemon(directory);
|
|
2358
|
-
if (alive && old === definition.content && alive.version === version) return;
|
|
2359
|
-
await checkServiceSupport();
|
|
2360
|
-
await mkdir7(dirname5(definition.path), { recursive: true });
|
|
2361
|
-
if (old !== definition.content) {
|
|
2362
|
-
const temp = definition.path + `.${process.pid}.tmp`;
|
|
2363
|
-
await writeFile2(temp, definition.content, { mode: 384 });
|
|
2364
|
-
await rename3(temp, definition.path);
|
|
2365
|
-
}
|
|
2366
|
-
if (process.platform === "darwin") {
|
|
2367
|
-
const domain = `gui/${process.getuid()}`, target = `${domain}/${definition.id}`;
|
|
2368
|
-
let loaded = false;
|
|
2369
|
-
try {
|
|
2370
|
-
await exec2("launchctl", ["print", target]);
|
|
2371
|
-
loaded = true;
|
|
2372
|
-
} catch {
|
|
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();
|
|
2373
3623
|
}
|
|
2374
|
-
|
|
2375
|
-
|
|
2376
|
-
|
|
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
|
+
}
|
|
2377
3633
|
}
|
|
2378
|
-
|
|
2379
|
-
|
|
2380
|
-
|
|
2381
|
-
await
|
|
2382
|
-
|
|
2383
|
-
|
|
2384
|
-
|
|
2385
|
-
|
|
2386
|
-
definition.id
|
|
2387
|
-
]);
|
|
2388
|
-
}
|
|
2389
|
-
const deadline = Date.now() + 15e3;
|
|
2390
|
-
while (Date.now() < deadline) {
|
|
2391
|
-
if ((await runningDaemon(directory))?.version === version) return;
|
|
2392
|
-
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();
|
|
2393
3642
|
}
|
|
2394
|
-
throw new Error(
|
|
2395
|
-
"Background service did not start. Run teammy logs and check the user service manager."
|
|
2396
|
-
);
|
|
2397
|
-
}
|
|
2398
|
-
async function wakeDaemon(directory, version) {
|
|
2399
|
-
await ensureDaemon(directory, version);
|
|
2400
|
-
await daemonRequest(directory, { method: "wake" });
|
|
2401
3643
|
}
|
|
2402
3644
|
|
|
2403
3645
|
// apps/client/output.ts
|
|
@@ -2417,15 +3659,94 @@ function json(value) {
|
|
|
2417
3659
|
process.stdout.write(JSON.stringify(value, null, 2) + "\n");
|
|
2418
3660
|
}
|
|
2419
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
|
+
|
|
2420
3741
|
// apps/client/client-command.ts
|
|
2421
3742
|
var locationOptions = {
|
|
2422
|
-
root: { type: "string", default:
|
|
2423
|
-
"state-dir": { type: "string", default:
|
|
3743
|
+
root: { type: "string", default: resolve10("workspaces") },
|
|
3744
|
+
"state-dir": { type: "string", default: join18(homedir5(), ".teammy", "client") },
|
|
2424
3745
|
json: { type: "boolean" },
|
|
2425
3746
|
help: { type: "boolean", short: "h" }
|
|
2426
3747
|
};
|
|
2427
3748
|
async function runClientCommand(command, args, version) {
|
|
2428
|
-
const { values } =
|
|
3749
|
+
const { values } = parseArgs3({
|
|
2429
3750
|
args,
|
|
2430
3751
|
options: {
|
|
2431
3752
|
...locationOptions,
|
|
@@ -2445,54 +3766,16 @@ async function runClientCommand(command, args, version) {
|
|
|
2445
3766
|
);
|
|
2446
3767
|
return 0;
|
|
2447
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
|
+
);
|
|
2448
3775
|
const root = await workspacePath(values.root), directory = await stateLocation(root, values["state-dir"]);
|
|
3776
|
+
await prepareClient(directory, version, { resume: !values["no-start"] });
|
|
2449
3777
|
const state = new ClientState(directory), log = new ClientLog(directory), runtime = new RuntimeState(state);
|
|
2450
3778
|
try {
|
|
2451
|
-
if (command === "logs") {
|
|
2452
|
-
const entries = log.tail(values.all ? void 0 : root);
|
|
2453
|
-
if (values.json) json(entries);
|
|
2454
|
-
else if (!entries.length) result("\u6CA1\u6709\u8BCA\u65AD\u8BB0\u5F55");
|
|
2455
|
-
else
|
|
2456
|
-
for (const item of entries)
|
|
2457
|
-
process.stdout.write(
|
|
2458
|
-
`${item.at} ${item.event}${item.detail ? " " + item.detail : ""}
|
|
2459
|
-
`
|
|
2460
|
-
);
|
|
2461
|
-
return 0;
|
|
2462
|
-
}
|
|
2463
|
-
if (command === "status") {
|
|
2464
|
-
const binding = state.binding(root);
|
|
2465
|
-
if (!binding) throw new Error("This workspace is not connected. Run teammy connect first.");
|
|
2466
|
-
const daemon = await runningDaemon(directory);
|
|
2467
|
-
const current = daemon ? await daemonRequest(directory, { method: "status", root }) : { enabled: runtime.enabled(root), running: false, status: runtime.status(root) };
|
|
2468
|
-
const { token: _, ...publicBinding } = binding;
|
|
2469
|
-
if (values.json)
|
|
2470
|
-
json({
|
|
2471
|
-
...current,
|
|
2472
|
-
binding: publicBinding,
|
|
2473
|
-
control: readControlStatus(state, root),
|
|
2474
|
-
daemon,
|
|
2475
|
-
logPath: log.path
|
|
2476
|
-
});
|
|
2477
|
-
else {
|
|
2478
|
-
const status = current.status;
|
|
2479
|
-
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";
|
|
2480
|
-
result(
|
|
2481
|
-
title,
|
|
2482
|
-
new ClientProjectState(state, root).cached()?.config.projectName ?? binding.workspaceId
|
|
2483
|
-
);
|
|
2484
|
-
if (status?.issue) result("\u5F85\u5904\u7406", status.issue);
|
|
2485
|
-
else if (status?.lastSuccessAt)
|
|
2486
|
-
process.stdout.write(` \u6700\u8FD1\u6210\u529F\uFF1A${status.lastSuccessAt}
|
|
2487
|
-
`);
|
|
2488
|
-
if (status?.pendingFiles || status?.pendingDownloads)
|
|
2489
|
-
process.stdout.write(
|
|
2490
|
-
` \u5F85\u4E0A\u4F20 ${status.pendingFiles} \xB7 \u5F85\u4E0B\u8F7D ${status.pendingDownloads}
|
|
2491
|
-
`
|
|
2492
|
-
);
|
|
2493
|
-
}
|
|
2494
|
-
return 0;
|
|
2495
|
-
}
|
|
2496
3779
|
const existing = state.binding(root);
|
|
2497
3780
|
const url = values.url ?? (values.reconnect ? existing?.serverUrl : void 0);
|
|
2498
3781
|
if (!url) throw new Error("--url is required.");
|
|
@@ -2523,12 +3806,16 @@ async function runClientCommand(command, args, version) {
|
|
|
2523
3806
|
tokenFile: values["token-file"],
|
|
2524
3807
|
reconnect: values.reconnect
|
|
2525
3808
|
});
|
|
3809
|
+
const collection = await setupSessions(state, root, directory, { enable: true });
|
|
3810
|
+
for (const issue of collection.issues) log.write("sessions.setup.failed", root, issue);
|
|
2526
3811
|
completePreparation(state, root);
|
|
2527
3812
|
runtime.enable(root, !values["no-start"]);
|
|
2528
3813
|
log.write("workspace.connected", root);
|
|
2529
3814
|
if (!values["no-start"]) await wakeDaemon(directory, version);
|
|
2530
|
-
if (values.json) json({ ...connected, syncEnabled: !values["no-start"] });
|
|
3815
|
+
if (values.json) json({ ...connected, collection, syncEnabled: !values["no-start"] });
|
|
2531
3816
|
else result(values["no-start"] ? "\u9879\u76EE\u5DF2\u8FDE\u63A5" : "\u5DF2\u8FDE\u63A5\uFF0C\u540E\u53F0\u540C\u6B65\u5DF2\u542F\u52A8", root);
|
|
3817
|
+
if (collection.issues.length && !values.json)
|
|
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");
|
|
2532
3819
|
return 0;
|
|
2533
3820
|
} catch (e) {
|
|
2534
3821
|
log.write(`${command}.failed`, root, diagnostic(e));
|
|
@@ -2539,18 +3826,18 @@ async function runClientCommand(command, args, version) {
|
|
|
2539
3826
|
}
|
|
2540
3827
|
|
|
2541
3828
|
// apps/client/files/synchronizer.ts
|
|
2542
|
-
import { randomUUID as
|
|
3829
|
+
import { randomUUID as randomUUID7 } from "node:crypto";
|
|
2543
3830
|
import { watch } from "node:fs";
|
|
2544
|
-
import { lstat as lstat4, mkdir as
|
|
2545
|
-
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";
|
|
2546
3833
|
import { performance } from "node:perf_hooks";
|
|
2547
3834
|
|
|
2548
3835
|
// packages/storage/local-blob-store.ts
|
|
2549
|
-
import { createHash as
|
|
2550
|
-
import { link, mkdir as
|
|
2551
|
-
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";
|
|
2552
3839
|
function hash(content) {
|
|
2553
|
-
return
|
|
3840
|
+
return createHash9("sha256").update(content).digest("hex");
|
|
2554
3841
|
}
|
|
2555
3842
|
function hasCode(error, code) {
|
|
2556
3843
|
return error instanceof Error && "code" in error && error.code === code;
|
|
@@ -2566,14 +3853,14 @@ var BlobIntegrityError = class extends Error {
|
|
|
2566
3853
|
var LocalBlobStore = class {
|
|
2567
3854
|
root;
|
|
2568
3855
|
constructor(root) {
|
|
2569
|
-
this.root =
|
|
3856
|
+
this.root = resolve11(root);
|
|
2570
3857
|
}
|
|
2571
3858
|
async put(content) {
|
|
2572
3859
|
const bytes = Buffer.from(content);
|
|
2573
3860
|
const sha256 = hash(bytes);
|
|
2574
3861
|
const target = this.pathFor(sha256);
|
|
2575
|
-
await
|
|
2576
|
-
const temporary = `${target}.${
|
|
3862
|
+
await mkdir11(dirname10(target), { recursive: true, mode: 448 });
|
|
3863
|
+
const temporary = `${target}.${randomUUID6()}.tmp`;
|
|
2577
3864
|
const file = await open4(temporary, "wx", 384);
|
|
2578
3865
|
try {
|
|
2579
3866
|
try {
|
|
@@ -2589,7 +3876,7 @@ var LocalBlobStore = class {
|
|
|
2589
3876
|
await this.get(sha256);
|
|
2590
3877
|
}
|
|
2591
3878
|
if (process.platform !== "win32") {
|
|
2592
|
-
let directory =
|
|
3879
|
+
let directory = dirname10(target);
|
|
2593
3880
|
for (; ; ) {
|
|
2594
3881
|
const handle = await open4(directory, "r");
|
|
2595
3882
|
try {
|
|
@@ -2597,8 +3884,8 @@ var LocalBlobStore = class {
|
|
|
2597
3884
|
} finally {
|
|
2598
3885
|
await handle.close();
|
|
2599
3886
|
}
|
|
2600
|
-
if (directory ===
|
|
2601
|
-
directory =
|
|
3887
|
+
if (directory === dirname10(this.root)) break;
|
|
3888
|
+
directory = dirname10(directory);
|
|
2602
3889
|
}
|
|
2603
3890
|
}
|
|
2604
3891
|
} finally {
|
|
@@ -2610,7 +3897,7 @@ var LocalBlobStore = class {
|
|
|
2610
3897
|
const target = this.pathFor(sha256);
|
|
2611
3898
|
let content;
|
|
2612
3899
|
try {
|
|
2613
|
-
content = await
|
|
3900
|
+
content = await readFile6(target);
|
|
2614
3901
|
} catch (error) {
|
|
2615
3902
|
if (hasCode(error, "ENOENT")) return void 0;
|
|
2616
3903
|
throw error;
|
|
@@ -2620,7 +3907,7 @@ var LocalBlobStore = class {
|
|
|
2620
3907
|
}
|
|
2621
3908
|
pathFor(sha256) {
|
|
2622
3909
|
if (!/^[a-f0-9]{64}$/.test(sha256)) throw new Error("Invalid SHA-256 content identifier.");
|
|
2623
|
-
return
|
|
3910
|
+
return join19(this.root, "sha256", sha256.slice(0, 2), sha256);
|
|
2624
3911
|
}
|
|
2625
3912
|
};
|
|
2626
3913
|
|
|
@@ -2638,14 +3925,17 @@ var PublicationQueue = class {
|
|
|
2638
3925
|
const pending = this.state.pending(root);
|
|
2639
3926
|
if (!pending || pending.retryAt > now) return false;
|
|
2640
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
|
+
}
|
|
2641
3933
|
const hashes = [
|
|
2642
3934
|
...new Set(
|
|
2643
|
-
pending.batch.changes.flatMap(
|
|
2644
|
-
(change) => change.kind === "upsert" ? [change.sha256] : []
|
|
2645
|
-
)
|
|
3935
|
+
pending.batch.changes.slice(pending.nextIndex, end).flatMap((change) => change.kind === "upsert" ? [change.sha256] : [])
|
|
2646
3936
|
)
|
|
2647
3937
|
];
|
|
2648
|
-
const { missing: missing2 } = await this.transport.missingBlobs(hashes);
|
|
3938
|
+
const { missing: missing2 } = hashes.length ? await this.transport.missingBlobs(hashes) : { missing: [] };
|
|
2649
3939
|
for (const hash2 of missing2) {
|
|
2650
3940
|
if (!hashes.includes(hash2)) throw new Error("Server requested unrelated content.");
|
|
2651
3941
|
const content = await this.blobs.get(hash2);
|
|
@@ -2654,9 +3944,12 @@ var PublicationQueue = class {
|
|
|
2654
3944
|
if (result2.sha256 !== hash2 || result2.size !== content.byteLength)
|
|
2655
3945
|
throw new Error("Server acknowledged different content.");
|
|
2656
3946
|
}
|
|
3947
|
+
this.state.uploaded(root, pending.batch.operationId, end);
|
|
3948
|
+
if (end < pending.batch.changes.length) return false;
|
|
2657
3949
|
this.state.acknowledge(root, await this.transport.commitFiles(pending.batch));
|
|
2658
3950
|
return true;
|
|
2659
3951
|
} catch (error) {
|
|
3952
|
+
if (error instanceof Error && error.name === "AbortError") throw error;
|
|
2660
3953
|
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
2661
3954
|
this.state.postpone(
|
|
2662
3955
|
root,
|
|
@@ -2680,17 +3973,17 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2680
3973
|
if (!binding) throw new Error("Connect this root before synchronizing.");
|
|
2681
3974
|
this.binding = binding;
|
|
2682
3975
|
this.client = new HubClient(binding.serverUrl, 3e4, binding, this.abort.signal);
|
|
2683
|
-
this.blobs = new LocalBlobStore(
|
|
3976
|
+
this.blobs = new LocalBlobStore(join20(stateDirectory, "blobs"));
|
|
2684
3977
|
this.projectScope = options.scope === "project";
|
|
2685
3978
|
this.owner = this.projectScope ? PROJECT_FILE_OWNER : binding.memberId;
|
|
2686
3979
|
this.publication = new FilePublicationState(state.db, options.scope);
|
|
2687
3980
|
this.queue = new PublicationQueue(this.publication, this.blobs, {
|
|
2688
3981
|
missingBlobs: (hashes) => this.client.missingBlobs(hashes),
|
|
2689
3982
|
uploadBlob: (hash2, bytes) => this.client.uploadBlob(hash2, bytes),
|
|
2690
|
-
commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch,
|
|
3983
|
+
commitFiles: (batch) => this.projectScope ? this.client.commitSharedFiles(batch, batch.configRevision) : this.client.commitFiles(batch)
|
|
2691
3984
|
});
|
|
2692
3985
|
this.index = new SyncState(state, root, options.scope);
|
|
2693
|
-
this.ownRoot = this.projectScope ? root :
|
|
3986
|
+
this.ownRoot = this.projectScope ? root : join20(root, binding.memberId);
|
|
2694
3987
|
this.common = this.projectScope ? void 0 : new _FileSynchronizer(state, root, stateDirectory, { ...options, scope: "project" });
|
|
2695
3988
|
const started = performance.now();
|
|
2696
3989
|
const epoch = Date.now();
|
|
@@ -2720,14 +4013,16 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2720
4013
|
watchIssues = [];
|
|
2721
4014
|
lastScan = -Infinity;
|
|
2722
4015
|
busy = false;
|
|
4016
|
+
capable = false;
|
|
2723
4017
|
stopped = false;
|
|
2724
4018
|
abort = new AbortController();
|
|
2725
4019
|
async checkCapabilities() {
|
|
2726
4020
|
const info = await this.client.serverInfo();
|
|
2727
|
-
if (!["identity", "blobs", "file-revisions"].every(
|
|
4021
|
+
if (!["identity", "blobs", "file-revisions", "atomic-file-publications"].every(
|
|
2728
4022
|
(capability) => info.capabilities.includes(capability)
|
|
2729
4023
|
))
|
|
2730
|
-
throw new Error("
|
|
4024
|
+
throw new Error("Upgrade the server to support complete directory publications.");
|
|
4025
|
+
this.capable = true;
|
|
2731
4026
|
}
|
|
2732
4027
|
setSettleMs(value) {
|
|
2733
4028
|
if (!Number.isSafeInteger(value) || value < 0 || value > 864e5)
|
|
@@ -2755,6 +4050,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2755
4050
|
};
|
|
2756
4051
|
try {
|
|
2757
4052
|
try {
|
|
4053
|
+
if (!this.capable) await this.checkCapabilities();
|
|
2758
4054
|
if (this.projectScope) {
|
|
2759
4055
|
try {
|
|
2760
4056
|
this.sharedConfig = await this.client.sharedFilesConfig();
|
|
@@ -2814,11 +4110,10 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2814
4110
|
}
|
|
2815
4111
|
const blocked = await this.refreshDirty(report.issues);
|
|
2816
4112
|
await this.audit(report.issues);
|
|
2817
|
-
report.published = await this.queue.flush(this.root, this.clock());
|
|
2818
4113
|
if (!this.publication.pending(this.root) && !this.stopped) {
|
|
2819
4114
|
await this.stage(blocked, report.issues, settleMs);
|
|
2820
|
-
report.published = await this.queue.flush(this.root, this.clock()) || report.published;
|
|
2821
4115
|
}
|
|
4116
|
+
report.published = await this.queue.flush(this.root, this.clock());
|
|
2822
4117
|
} catch (error) {
|
|
2823
4118
|
report.issues.push(`publish: ${message(error)}`);
|
|
2824
4119
|
}
|
|
@@ -2862,7 +4157,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2862
4157
|
throw new Error("Manifest changed version during pagination.");
|
|
2863
4158
|
revision = page.revision;
|
|
2864
4159
|
files.push(...page.files);
|
|
2865
|
-
if (page.nextCursor === null) return { revision, files };
|
|
4160
|
+
if (page.nextCursor === null) return { revision, complete: page.complete, files };
|
|
2866
4161
|
if (page.nextCursor === after || files.length > 1e5)
|
|
2867
4162
|
throw new Error("Manifest pagination did not make bounded progress.");
|
|
2868
4163
|
after = page.nextCursor;
|
|
@@ -2889,10 +4184,12 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2889
4184
|
}
|
|
2890
4185
|
}
|
|
2891
4186
|
const ownerPath = this.projectScope ? this.root : await safePath(this.root, this.owner);
|
|
2892
|
-
await
|
|
4187
|
+
await mkdir12(ownerPath).catch((error) => {
|
|
2893
4188
|
if (error.code !== "EEXIST") throw error;
|
|
2894
4189
|
});
|
|
2895
|
-
this.publication.initializePublication(this.root, manifest.revision, manifest.files
|
|
4190
|
+
this.publication.initializePublication(this.root, manifest.revision, manifest.files, {
|
|
4191
|
+
complete: manifest.complete
|
|
4192
|
+
});
|
|
2896
4193
|
}
|
|
2897
4194
|
listen() {
|
|
2898
4195
|
if (this.options.watch === false || this.watcher || this.stopped) return;
|
|
@@ -2902,7 +4199,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2902
4199
|
this.lastScan = -Infinity;
|
|
2903
4200
|
return;
|
|
2904
4201
|
}
|
|
2905
|
-
const path = filename.toString().split(
|
|
4202
|
+
const path = filename.toString().split(sep5).join("/");
|
|
2906
4203
|
if (!this.excludes(path)) this.index.event(path, this.clock());
|
|
2907
4204
|
});
|
|
2908
4205
|
this.watcher.on("error", (error) => {
|
|
@@ -2926,6 +4223,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2926
4223
|
}
|
|
2927
4224
|
}
|
|
2928
4225
|
issues.push(...scan.issues);
|
|
4226
|
+
return scan.complete;
|
|
2929
4227
|
}
|
|
2930
4228
|
async refreshDirty(issues) {
|
|
2931
4229
|
const blocked = /* @__PURE__ */ new Set();
|
|
@@ -2966,73 +4264,80 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
2966
4264
|
}
|
|
2967
4265
|
}
|
|
2968
4266
|
async stage(blocked, issues, settleMs) {
|
|
2969
|
-
const
|
|
2970
|
-
|
|
2971
|
-
|
|
2972
|
-
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;
|
|
2973
4277
|
const baseline = this.publication.ownerFiles(this.root);
|
|
2974
4278
|
const planned = new Map(baseline);
|
|
2975
4279
|
const changes = [];
|
|
2976
4280
|
const snapshots = [];
|
|
2977
|
-
let bytes = 0;
|
|
2978
4281
|
ready.sort((a, b) => Number(a.signature !== null) - Number(b.signature !== null));
|
|
2979
4282
|
for (const row of ready) {
|
|
2980
|
-
if (this.stopped
|
|
4283
|
+
if (this.stopped) return;
|
|
2981
4284
|
try {
|
|
2982
4285
|
if (row.signature === null) {
|
|
2983
|
-
const current = await metadata(this.ownRoot, row.path);
|
|
2984
|
-
if (current && !current.directory) {
|
|
2985
|
-
this.index.event(row.path, this.clock());
|
|
2986
|
-
continue;
|
|
2987
|
-
}
|
|
2988
4286
|
if (baseline.has(row.path) && row.seenLocal) {
|
|
2989
4287
|
changes.push({ kind: "delete", path: row.path });
|
|
2990
4288
|
planned.delete(row.path);
|
|
2991
|
-
} else {
|
|
2992
|
-
this.index.clean(row.path, row.generation, null, this.clock());
|
|
2993
|
-
continue;
|
|
2994
4289
|
}
|
|
2995
4290
|
snapshots.push({ path: row.path, generation: row.generation, hash: null });
|
|
2996
4291
|
continue;
|
|
2997
4292
|
}
|
|
2998
|
-
const key = row.path.toLowerCase();
|
|
2999
|
-
const collision = [...planned.keys()].some(
|
|
3000
|
-
(path) => path !== row.path && (path.toLowerCase() === key || path.toLowerCase().startsWith(`${key}/`) || key.startsWith(`${path.toLowerCase()}/`))
|
|
3001
|
-
);
|
|
3002
|
-
if (collision) {
|
|
3003
|
-
issues.push(
|
|
3004
|
-
`${row.path}: conflicts with a preserved server path; rename the local file to publish it.`
|
|
3005
|
-
);
|
|
3006
|
-
continue;
|
|
3007
|
-
}
|
|
3008
4293
|
const local = await readStable(this.ownRoot, row.path);
|
|
3009
|
-
bytes += local.bytes.length;
|
|
3010
4294
|
if (local.signature !== row.signature || this.index.get(row.path)?.generation !== row.generation) {
|
|
3011
4295
|
this.index.event(row.path, this.clock());
|
|
3012
|
-
|
|
4296
|
+
return;
|
|
3013
4297
|
}
|
|
3014
|
-
if (baseline.get(row.path)?.sha256
|
|
3015
|
-
this.
|
|
3016
|
-
|
|
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);
|
|
3017
4302
|
}
|
|
3018
|
-
const ref = await this.blobs.put(local.bytes);
|
|
3019
|
-
changes.push({ kind: "upsert", path: row.path, ...ref });
|
|
3020
|
-
planned.set(row.path, ref);
|
|
3021
4303
|
snapshots.push({ path: row.path, generation: row.generation, hash: local.sha256 });
|
|
3022
4304
|
} catch (error) {
|
|
3023
4305
|
this.index.event(row.path, this.clock());
|
|
3024
4306
|
issues.push(`${row.path}: ${message(error)}`);
|
|
4307
|
+
return;
|
|
3025
4308
|
}
|
|
3026
4309
|
}
|
|
3027
|
-
|
|
3028
|
-
const
|
|
3029
|
-
|
|
3030
|
-
|
|
3031
|
-
|
|
3032
|
-
|
|
3033
|
-
|
|
3034
|
-
|
|
3035
|
-
}
|
|
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
|
+
}
|
|
3036
4341
|
for (const snapshot of snapshots)
|
|
3037
4342
|
this.index.clean(snapshot.path, snapshot.generation, snapshot.hash, this.clock());
|
|
3038
4343
|
}
|
|
@@ -3075,7 +4380,7 @@ var FileSynchronizer = class _FileSynchronizer {
|
|
|
3075
4380
|
}
|
|
3076
4381
|
const job = this.index.job(owner);
|
|
3077
4382
|
const ownerRoot = this.projectScope ? this.root : await safePath(this.root, owner);
|
|
3078
|
-
await
|
|
4383
|
+
await mkdir12(ownerRoot).catch((error) => {
|
|
3079
4384
|
if (error.code !== "EEXIST") throw error;
|
|
3080
4385
|
});
|
|
3081
4386
|
const ownerStat = await lstat4(ownerRoot);
|
|
@@ -3130,24 +4435,27 @@ function message(error) {
|
|
|
3130
4435
|
}
|
|
3131
4436
|
|
|
3132
4437
|
// apps/client/sessions/synchronizer.ts
|
|
3133
|
-
import { randomUUID as
|
|
3134
|
-
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";
|
|
3135
4441
|
|
|
3136
4442
|
// packages/protocol/session-key.ts
|
|
3137
|
-
import { createHash as
|
|
4443
|
+
import { createHash as createHash10 } from "node:crypto";
|
|
3138
4444
|
function sessionKey(agent, nativeId) {
|
|
3139
|
-
return
|
|
4445
|
+
return createHash10("sha256").update(JSON.stringify([agent, nativeId])).digest("hex");
|
|
3140
4446
|
}
|
|
3141
4447
|
|
|
3142
4448
|
// apps/client/sessions/source.ts
|
|
3143
|
-
import {
|
|
3144
|
-
import {
|
|
4449
|
+
import { createReadStream, realpathSync } from "node:fs";
|
|
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";
|
|
4452
|
+
import { createInterface as createInterface3 } from "node:readline";
|
|
3145
4453
|
import * as zlib from "node:zlib";
|
|
3146
4454
|
|
|
3147
4455
|
// apps/client/sessions/cursor.ts
|
|
3148
|
-
import { createHash as
|
|
3149
|
-
import { basename as
|
|
3150
|
-
import { DatabaseSync as
|
|
4456
|
+
import { createHash as createHash11 } from "node:crypto";
|
|
4457
|
+
import { basename as basename3, dirname as dirname11, resolve as resolve12 } from "node:path";
|
|
4458
|
+
import { DatabaseSync as DatabaseSync6 } from "node:sqlite";
|
|
3151
4459
|
import { fileURLToPath } from "node:url";
|
|
3152
4460
|
function fields(bytes) {
|
|
3153
4461
|
const data = Buffer.from(bytes);
|
|
@@ -3184,34 +4492,31 @@ function fields(bytes) {
|
|
|
3184
4492
|
}
|
|
3185
4493
|
return result2;
|
|
3186
4494
|
}
|
|
3187
|
-
function readCursor(path, projectDir) {
|
|
3188
|
-
const db = new
|
|
4495
|
+
function readCursor(path, projectDir, checkCwd) {
|
|
4496
|
+
const db = new DatabaseSync6(path, { readOnly: true });
|
|
3189
4497
|
try {
|
|
3190
4498
|
db.exec("PRAGMA query_only=ON; PRAGMA busy_timeout=100; BEGIN");
|
|
3191
4499
|
if (db.prepare("PRAGMA user_version").get()?.user_version !== 1)
|
|
3192
4500
|
throw new Error("Unsupported Cursor CLI store version (expected 1).");
|
|
3193
|
-
const row = db.prepare("SELECT value FROM meta WHERE key='0'
|
|
4501
|
+
const row = db.prepare("SELECT value FROM meta WHERE key='0'").get();
|
|
3194
4502
|
const hex = row?.value;
|
|
3195
4503
|
if (typeof hex !== "string" || !/^(?:[a-fA-F0-9]{2})+$/.test(hex))
|
|
3196
4504
|
throw new Error("Invalid Cursor metadata.");
|
|
3197
4505
|
const meta = object(JSON.parse(Buffer.from(hex, "hex").toString("utf8")));
|
|
3198
|
-
if (typeof meta.agentId !== "string" || meta.agentId !==
|
|
4506
|
+
if (typeof meta.agentId !== "string" || meta.agentId !== basename3(dirname11(path)) || meta.subagentInfo != null)
|
|
3199
4507
|
throw new Error("Invalid or delegated Cursor session identity.");
|
|
3200
|
-
let budget = 64 * 1024 * 1024;
|
|
3201
4508
|
const blob = (id) => {
|
|
3202
4509
|
if (!/^[a-f0-9]{64}$/.test(id)) throw new Error("Invalid Cursor blob reference.");
|
|
3203
|
-
const value = db.prepare("SELECT data FROM blobs WHERE id=?
|
|
3204
|
-
if (!(value instanceof Uint8Array))
|
|
3205
|
-
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.");
|
|
3206
4512
|
const bytes = Buffer.from(value);
|
|
3207
|
-
|
|
3208
|
-
if (createHash10("sha256").update(bytes).digest("hex") !== id)
|
|
4513
|
+
if (createHash11("sha256").update(bytes).digest("hex") !== id)
|
|
3209
4514
|
throw new Error("Cursor blob failed integrity verification.");
|
|
3210
4515
|
return bytes;
|
|
3211
4516
|
};
|
|
3212
4517
|
const root = fields(blob(String(meta.latestRootBlobId)));
|
|
3213
|
-
const bucket =
|
|
3214
|
-
const md5 = (cwd2) =>
|
|
4518
|
+
const bucket = basename3(dirname11(dirname11(path)));
|
|
4519
|
+
const md5 = (cwd2) => createHash11("md5").update(resolve12(cwd2)).digest("hex");
|
|
3215
4520
|
let cwd = md5(projectDir) === bucket ? projectDir : void 0;
|
|
3216
4521
|
for (const uri of root.get(9) ?? []) {
|
|
3217
4522
|
try {
|
|
@@ -3224,11 +4529,11 @@ function readCursor(path, projectDir) {
|
|
|
3224
4529
|
}
|
|
3225
4530
|
}
|
|
3226
4531
|
if (!cwd) throw new Error("Cursor workspace cannot be verified against its storage bucket.");
|
|
4532
|
+
checkCwd?.(cwd);
|
|
3227
4533
|
const refs = [];
|
|
3228
4534
|
for (const archive of root.get(13) ?? root.get(11) ?? [])
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
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);
|
|
3232
4537
|
const messages = [];
|
|
3233
4538
|
const counts = /* @__PURE__ */ new Map();
|
|
3234
4539
|
for (const ref of refs) {
|
|
@@ -3246,7 +4551,7 @@ function readCursor(path, projectDir) {
|
|
|
3246
4551
|
if (start >= 0 && end > start) text = text.slice(start + 12, end).trim();
|
|
3247
4552
|
}
|
|
3248
4553
|
if (!text.trim()) continue;
|
|
3249
|
-
const id =
|
|
4554
|
+
const id = createHash11("sha256").update(JSON.stringify([message2.role, cursor.requestId ?? "", text])).digest("hex");
|
|
3250
4555
|
const count = (counts.get(id) ?? 0) + 1;
|
|
3251
4556
|
counts.set(id, count);
|
|
3252
4557
|
messages.push({
|
|
@@ -3270,9 +4575,14 @@ function readCursor(path, projectDir) {
|
|
|
3270
4575
|
}
|
|
3271
4576
|
|
|
3272
4577
|
// apps/client/sessions/source.ts
|
|
3273
|
-
|
|
4578
|
+
function within(project, cwd) {
|
|
4579
|
+
const rel = relative5(project, cwd);
|
|
4580
|
+
return rel !== ".." && !rel.startsWith(`..${sep6}`) && !isAbsolute5(rel);
|
|
4581
|
+
}
|
|
4582
|
+
var cursorEncoded = (cwd) => cwd.replace(/^[/\\]+/, "").replace(/[/\\]/g, "-");
|
|
4583
|
+
var cursorDatabase = (path) => basename4(path) === "store.db";
|
|
3274
4584
|
async function sourceSignature(path, agent) {
|
|
3275
|
-
const paths = agent === "cursor" ? [path, `${path}-wal`] : [path];
|
|
4585
|
+
const paths = agent === "cursor" && cursorDatabase(path) ? [path, `${path}-wal`] : [path];
|
|
3276
4586
|
const signatures = [];
|
|
3277
4587
|
for (const file of paths) {
|
|
3278
4588
|
try {
|
|
@@ -3293,60 +4603,142 @@ async function sourceSignature(path, agent) {
|
|
|
3293
4603
|
}
|
|
3294
4604
|
return JSON.stringify(signatures);
|
|
3295
4605
|
}
|
|
4606
|
+
async function cursorScope(path, project) {
|
|
4607
|
+
let container = dirname12(path);
|
|
4608
|
+
while (basename4(container) !== "agent-transcripts" && dirname12(container) !== container)
|
|
4609
|
+
container = dirname12(container);
|
|
4610
|
+
if (basename4(container) !== "agent-transcripts")
|
|
4611
|
+
throw new Error("Cursor transcript workspace cannot be verified.");
|
|
4612
|
+
const encoded = basename4(dirname12(container));
|
|
4613
|
+
const matches = [];
|
|
4614
|
+
const visit = async (directory) => {
|
|
4615
|
+
const prefix = cursorEncoded(directory);
|
|
4616
|
+
if (encoded === prefix) {
|
|
4617
|
+
matches.push(directory);
|
|
4618
|
+
return;
|
|
4619
|
+
}
|
|
4620
|
+
if (!encoded.startsWith(prefix + "-")) return;
|
|
4621
|
+
for (const entry of await readdir2(directory, { withFileTypes: true })) {
|
|
4622
|
+
if (entry.isDirectory() && !entry.isSymbolicLink()) await visit(join21(directory, entry.name));
|
|
4623
|
+
}
|
|
4624
|
+
};
|
|
4625
|
+
await visit(project);
|
|
4626
|
+
if (matches.length !== 1)
|
|
4627
|
+
throw new Error("Session cwd is outside the registered project or ambiguous.");
|
|
4628
|
+
return matches[0];
|
|
4629
|
+
}
|
|
3296
4630
|
async function readSource(agent, path, projectDir) {
|
|
3297
4631
|
const signature2 = await sourceSignature(path, agent);
|
|
3298
|
-
|
|
3299
|
-
if (agent === "cursor"
|
|
3300
|
-
|
|
3301
|
-
|
|
3302
|
-
|
|
3303
|
-
|
|
3304
|
-
|
|
3305
|
-
|
|
3306
|
-
|
|
3307
|
-
|
|
3308
|
-
|
|
3309
|
-
|
|
3310
|
-
|
|
3311
|
-
|
|
3312
|
-
|
|
3313
|
-
|
|
3314
|
-
|
|
3315
|
-
|
|
3316
|
-
|
|
4632
|
+
const project = await realpath8(projectDir);
|
|
4633
|
+
if (agent === "cursor" && cursorDatabase(path)) {
|
|
4634
|
+
const snapshot2 = readCursor(path, projectDir, (cwd) => {
|
|
4635
|
+
if (!within(project, realpathSync(cwd)))
|
|
4636
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
4637
|
+
});
|
|
4638
|
+
return { snapshot: snapshot2, incomplete: false, skipped: 0, signature: signature2 };
|
|
4639
|
+
}
|
|
4640
|
+
const cursor = agent === "cursor" ? { nativeId: basename4(path, ".jsonl"), cwd: await cursorScope(path, projectDir) } : void 0;
|
|
4641
|
+
const normalizer = new JsonlNormalizer(agent, cursor);
|
|
4642
|
+
const stream = createReadStream(path, {
|
|
4643
|
+
end: Math.max(0, Number(JSON.parse(signature2)[0][2]) - 1)
|
|
4644
|
+
});
|
|
4645
|
+
let input = stream;
|
|
4646
|
+
if (agent === "dsh" && path.endsWith(".zstd")) {
|
|
4647
|
+
const decompress = zlib.createZstdDecompress;
|
|
4648
|
+
if (!decompress) {
|
|
4649
|
+
stream.destroy();
|
|
4650
|
+
throw new Error("Compressed DSH sessions require Node.js with zstd support (22.15+ or 24+).");
|
|
3317
4651
|
}
|
|
3318
|
-
|
|
3319
|
-
|
|
3320
|
-
|
|
3321
|
-
|
|
3322
|
-
|
|
3323
|
-
|
|
3324
|
-
|
|
4652
|
+
const decoder = decompress();
|
|
4653
|
+
stream.on("error", (e) => decoder.destroy(e));
|
|
4654
|
+
input = stream.pipe(decoder);
|
|
4655
|
+
}
|
|
4656
|
+
const lines = createInterface3({ input, crlfDelay: Infinity });
|
|
4657
|
+
const checkedCwds = /* @__PURE__ */ new Set();
|
|
4658
|
+
let line = 0, skipped = 0, lastBad = false;
|
|
4659
|
+
try {
|
|
4660
|
+
for await (const text of lines) {
|
|
4661
|
+
line++;
|
|
4662
|
+
if (!text.trim()) continue;
|
|
4663
|
+
const row = parseRecord(text);
|
|
4664
|
+
lastBad = !row;
|
|
4665
|
+
if (!row) {
|
|
4666
|
+
skipped++;
|
|
4667
|
+
continue;
|
|
4668
|
+
}
|
|
4669
|
+
const cwd = row.cwd ?? (row.type === "session_meta" ? object(row.payload).cwd : void 0);
|
|
4670
|
+
if (typeof cwd === "string" && !checkedCwds.has(cwd)) {
|
|
4671
|
+
const actual = await realpath8(cwd).catch((error) => {
|
|
4672
|
+
if (error.code === "ENOENT" && !within(project, cwd))
|
|
4673
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
4674
|
+
throw error;
|
|
4675
|
+
});
|
|
4676
|
+
if (!within(project, actual))
|
|
4677
|
+
throw new Error("Session cwd is outside the registered project.");
|
|
4678
|
+
checkedCwds.add(cwd);
|
|
4679
|
+
}
|
|
4680
|
+
normalizer.consume(row, line);
|
|
3325
4681
|
}
|
|
3326
|
-
|
|
4682
|
+
} finally {
|
|
4683
|
+
lines.close();
|
|
4684
|
+
stream.destroy();
|
|
4685
|
+
if (input !== stream) input.destroy();
|
|
3327
4686
|
}
|
|
3328
|
-
|
|
4687
|
+
const before = JSON.parse(signature2)[0];
|
|
4688
|
+
const after = JSON.parse(await sourceSignature(path, agent))[0];
|
|
4689
|
+
if (before[0] !== after[0] || before[1] !== after[1] || Number(after[2]) < Number(before[2]) || before[2] === after[2] && before.join() !== after.join())
|
|
3329
4690
|
throw new Error("Session source changed while reading; retry next cycle.");
|
|
3330
|
-
const
|
|
3331
|
-
|
|
3332
|
-
const rel = relative4(project, cwd);
|
|
3333
|
-
if (rel === ".." || rel.startsWith(`..${sep5}`) || isAbsolute4(rel))
|
|
4691
|
+
const snapshot = normalizer.finish();
|
|
4692
|
+
if (!within(project, await realpath8(snapshot.cwd)))
|
|
3334
4693
|
throw new Error("Session cwd is outside the registered project.");
|
|
3335
|
-
return {
|
|
4694
|
+
return { snapshot, incomplete: lastBad, skipped, signature: signature2 };
|
|
3336
4695
|
}
|
|
3337
4696
|
|
|
3338
4697
|
// apps/client/sessions/discovery.ts
|
|
3339
4698
|
import { opendir as opendir2 } from "node:fs/promises";
|
|
3340
|
-
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
|
|
3341
4733
|
async function* candidates(directory, agent) {
|
|
3342
4734
|
const queue = [directory];
|
|
3343
4735
|
while (queue.length) {
|
|
3344
4736
|
const current = queue.shift();
|
|
3345
4737
|
const dir = await opendir2(current);
|
|
3346
4738
|
for await (const entry of dir) {
|
|
3347
|
-
const path =
|
|
4739
|
+
const path = join22(current, entry.name);
|
|
3348
4740
|
if (entry.isDirectory()) queue.push(path);
|
|
3349
|
-
if (entry.isFile() && (agent === "cursor" ? entry.name === "store.db" : entry.name.endsWith(".jsonl") || agent === "dsh" && entry.name.endsWith(".jsonl.zstd")))
|
|
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")))
|
|
3350
4742
|
yield path;
|
|
3351
4743
|
else yield null;
|
|
3352
4744
|
}
|
|
@@ -3358,10 +4750,12 @@ var SessionDiscovery = class {
|
|
|
3358
4750
|
}
|
|
3359
4751
|
state;
|
|
3360
4752
|
scans = /* @__PURE__ */ new Map();
|
|
4753
|
+
pending = false;
|
|
3361
4754
|
async cycle() {
|
|
4755
|
+
this.pending = false;
|
|
3362
4756
|
const issues = [];
|
|
3363
4757
|
let entries = 500;
|
|
3364
|
-
|
|
4758
|
+
const started = performance2.now();
|
|
3365
4759
|
const configs = this.state.directories();
|
|
3366
4760
|
for (const [directory, scan] of this.scans) {
|
|
3367
4761
|
if (!configs.some((config) => config.directory === directory)) {
|
|
@@ -3377,7 +4771,7 @@ var SessionDiscovery = class {
|
|
|
3377
4771
|
}
|
|
3378
4772
|
if (scan.nextAt > Date.now()) continue;
|
|
3379
4773
|
try {
|
|
3380
|
-
while (entries > 0 &&
|
|
4774
|
+
while (entries > 0 && performance2.now() - started < 1e3) {
|
|
3381
4775
|
entries--;
|
|
3382
4776
|
const entry = await scan.iterator.next();
|
|
3383
4777
|
if (entry.done) {
|
|
@@ -3390,35 +4784,42 @@ var SessionDiscovery = class {
|
|
|
3390
4784
|
const signature2 = await sourceSignature(path, config.agent);
|
|
3391
4785
|
const db = this.state.state.db;
|
|
3392
4786
|
if (db.prepare(
|
|
3393
|
-
"SELECT 1 FROM session_discovery_cache WHERE root=? AND path=? AND signature=?"
|
|
3394
|
-
).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]))
|
|
3395
4789
|
continue;
|
|
3396
|
-
reads--;
|
|
3397
4790
|
try {
|
|
3398
|
-
const result2 = await
|
|
3399
|
-
|
|
4791
|
+
const result2 = await sessionWorkQueue.run(
|
|
4792
|
+
() => readSource(config.agent, path, config.projectDir)
|
|
4793
|
+
);
|
|
4794
|
+
if (!result2.snapshot.messages.length) continue;
|
|
3400
4795
|
this.state.track(config.agent, path, config.projectDir);
|
|
3401
4796
|
} catch (error) {
|
|
3402
4797
|
const message2 = error instanceof Error ? error.message : "Discovery failed.";
|
|
3403
4798
|
if (!message2.includes("outside the registered project"))
|
|
3404
4799
|
issues.push(`${path}: ${message2}`);
|
|
3405
|
-
if (
|
|
4800
|
+
if (error.code === "ENOENT" || /locked|busy|changed while reading/i.test(message2))
|
|
4801
|
+
continue;
|
|
3406
4802
|
}
|
|
3407
4803
|
db.prepare(
|
|
3408
|
-
"INSERT INTO session_discovery_cache VALUES (
|
|
3409
|
-
).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]);
|
|
3410
4806
|
}
|
|
3411
4807
|
} catch (error) {
|
|
3412
|
-
|
|
3413
|
-
|
|
3414
|
-
|
|
4808
|
+
if (error.code !== "ENOENT")
|
|
4809
|
+
issues.push(
|
|
4810
|
+
`${config.directory}: ${error instanceof Error ? error.message : "Discovery failed."}`
|
|
4811
|
+
);
|
|
3415
4812
|
await scan.iterator.return(void 0);
|
|
3416
4813
|
scan.iterator = candidates(config.directory, config.agent);
|
|
3417
4814
|
scan.nextAt = Date.now() + 3e5;
|
|
3418
4815
|
}
|
|
3419
4816
|
}
|
|
4817
|
+
this.pending = [...this.scans.values()].some((scan) => scan.nextAt <= Date.now());
|
|
3420
4818
|
return issues;
|
|
3421
4819
|
}
|
|
4820
|
+
async refresh() {
|
|
4821
|
+
for (const scan of this.scans.values()) scan.nextAt = 0;
|
|
4822
|
+
}
|
|
3422
4823
|
async close() {
|
|
3423
4824
|
for (const scan of this.scans.values()) await scan.iterator.return(void 0);
|
|
3424
4825
|
}
|
|
@@ -3429,10 +4830,11 @@ var SessionSynchronizer = class {
|
|
|
3429
4830
|
constructor(state, root, directory, signal) {
|
|
3430
4831
|
this.root = root;
|
|
3431
4832
|
this.directory = directory;
|
|
4833
|
+
this.signal = signal;
|
|
3432
4834
|
const binding = state.binding(root);
|
|
3433
4835
|
if (!binding) throw new Error("Connect this root before collecting sessions.");
|
|
3434
4836
|
this.client = new HubClient(binding.serverUrl, 1e4, binding, signal);
|
|
3435
|
-
this.blobs = new LocalBlobStore(
|
|
4837
|
+
this.blobs = new LocalBlobStore(join23(directory, "blobs"));
|
|
3436
4838
|
this.index = new SessionState(state, root);
|
|
3437
4839
|
this.discovery = new SessionDiscovery(this.index);
|
|
3438
4840
|
}
|
|
@@ -3443,11 +4845,29 @@ var SessionSynchronizer = class {
|
|
|
3443
4845
|
blobs;
|
|
3444
4846
|
discovery;
|
|
3445
4847
|
capable = false;
|
|
4848
|
+
passAt;
|
|
4849
|
+
signal;
|
|
3446
4850
|
async cycle() {
|
|
3447
|
-
const report = {
|
|
3448
|
-
|
|
3449
|
-
|
|
3450
|
-
|
|
4851
|
+
const report = {
|
|
4852
|
+
published: 0,
|
|
4853
|
+
checked: 0,
|
|
4854
|
+
pending: false,
|
|
4855
|
+
issues: [],
|
|
4856
|
+
warnings: []
|
|
4857
|
+
};
|
|
4858
|
+
report.issues.push(
|
|
4859
|
+
...await consumeHints(
|
|
4860
|
+
this.directory,
|
|
4861
|
+
this.root,
|
|
4862
|
+
this.index,
|
|
4863
|
+
() => this.discovery.refresh()
|
|
4864
|
+
)
|
|
4865
|
+
);
|
|
4866
|
+
report.warnings.push(...await this.discovery.cycle());
|
|
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;
|
|
3451
4871
|
if (sources.length && !this.capable) {
|
|
3452
4872
|
const info = await this.client.serverInfo();
|
|
3453
4873
|
if (!["blobs", "session-revisions"].every(
|
|
@@ -3456,48 +4876,88 @@ var SessionSynchronizer = class {
|
|
|
3456
4876
|
throw new Error("Server does not support session publication. Upgrade the server first.");
|
|
3457
4877
|
this.capable = true;
|
|
3458
4878
|
}
|
|
3459
|
-
let
|
|
3460
|
-
let
|
|
3461
|
-
|
|
3462
|
-
|
|
3463
|
-
|
|
3464
|
-
|
|
3465
|
-
|
|
3466
|
-
|
|
3467
|
-
if (source
|
|
3468
|
-
|
|
3469
|
-
|
|
3470
|
-
continue;
|
|
3471
|
-
}
|
|
3472
|
-
uploads--;
|
|
3473
|
-
await this.flush(source);
|
|
3474
|
-
report.published++;
|
|
3475
|
-
continue;
|
|
3476
|
-
}
|
|
3477
|
-
const signature2 = await sourceSignature(source.path, source.agent);
|
|
3478
|
-
if (signature2 === source.signature && Date.now() - source.verifiedAt < 864e5) {
|
|
3479
|
-
this.index.checked(source.path);
|
|
3480
|
-
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++;
|
|
3481
4890
|
}
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
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;
|
|
3489
4912
|
}
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
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);
|
|
4940
|
+
report.warnings.push(
|
|
4941
|
+
`${source.path}: kept the previous snapshot because the damaged copy contains fewer messages.`
|
|
3494
4942
|
);
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
4943
|
+
return;
|
|
4944
|
+
}
|
|
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);
|
|
3499
4959
|
let revision = source.revision;
|
|
3500
|
-
if (!source.key) {
|
|
4960
|
+
if (!source.key && !this.index.publication(key)) {
|
|
3501
4961
|
try {
|
|
3502
4962
|
const owner = this.index.state.binding(this.root).memberId;
|
|
3503
4963
|
const current = await this.client.session(owner, key);
|
|
@@ -3510,27 +4970,33 @@ var SessionSynchronizer = class {
|
|
|
3510
4970
|
if (!(error instanceof HubRequestError && error.code === "SESSION_NOT_FOUND"))
|
|
3511
4971
|
throw error;
|
|
3512
4972
|
}
|
|
3513
|
-
|
|
4973
|
+
}
|
|
4974
|
+
this.index.baseline(source.path, key, revision);
|
|
4975
|
+
source = this.index.get(source.path);
|
|
4976
|
+
revision = source.revision;
|
|
4977
|
+
if (source.pending) return;
|
|
4978
|
+
if (source.agent === "cursor" && source.selectedPath && cursorDatabase(source.selectedPath) && !cursorDatabase(source.path)) {
|
|
4979
|
+
this.index.observed(source.path, observed.signature);
|
|
4980
|
+
return;
|
|
3514
4981
|
}
|
|
3515
4982
|
if (blob.sha256 === source.hash) {
|
|
3516
4983
|
this.index.acknowledge(source.path, revision, blob.sha256, observed.signature);
|
|
3517
|
-
|
|
4984
|
+
return;
|
|
3518
4985
|
}
|
|
3519
4986
|
this.index.stage(
|
|
3520
4987
|
source.path,
|
|
3521
|
-
{ operationId:
|
|
4988
|
+
{ operationId: randomUUID8(), sessionKey: key, parentRevision: revision, blob },
|
|
3522
4989
|
observed.signature
|
|
3523
4990
|
);
|
|
3524
|
-
uploads--;
|
|
3525
4991
|
await this.flush(this.index.get(source.path));
|
|
3526
4992
|
report.published++;
|
|
3527
|
-
}
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
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}`);
|
|
3532
4999
|
}
|
|
3533
|
-
return report;
|
|
3534
5000
|
}
|
|
3535
5001
|
async flush(source) {
|
|
3536
5002
|
const request = source.pending;
|
|
@@ -3552,9 +5018,11 @@ var SessionSynchronizer = class {
|
|
|
3552
5018
|
source.path,
|
|
3553
5019
|
result2.revision,
|
|
3554
5020
|
request.blob.sha256,
|
|
3555
|
-
source.pendingSignature
|
|
5021
|
+
source.pendingSignature,
|
|
5022
|
+
source.pendingParserVersion
|
|
3556
5023
|
);
|
|
3557
5024
|
} catch (error) {
|
|
5025
|
+
if (this.signal?.aborted) throw error;
|
|
3558
5026
|
const blocked = error instanceof HubRequestError && error.status >= 400 && error.status < 500 && ![408, 429].includes(error.status);
|
|
3559
5027
|
this.index.postpone(
|
|
3560
5028
|
source,
|
|
@@ -3670,7 +5138,13 @@ var SyncJob = class {
|
|
|
3670
5138
|
pendingPublication: false,
|
|
3671
5139
|
issues: []
|
|
3672
5140
|
};
|
|
3673
|
-
let sessions = {
|
|
5141
|
+
let sessions = {
|
|
5142
|
+
published: 0,
|
|
5143
|
+
checked: 0,
|
|
5144
|
+
pending: false,
|
|
5145
|
+
issues: [],
|
|
5146
|
+
warnings: []
|
|
5147
|
+
};
|
|
3674
5148
|
try {
|
|
3675
5149
|
await verifyWorkspace(this.state, this.root);
|
|
3676
5150
|
if (!this.controlStarted) {
|
|
@@ -3680,6 +5154,8 @@ var SyncJob = class {
|
|
|
3680
5154
|
}
|
|
3681
5155
|
if (this.files) report = await this.files.cycle();
|
|
3682
5156
|
sessions = await this.sessions.cycle();
|
|
5157
|
+
for (const warning of sessions.warnings)
|
|
5158
|
+
this.log.write("sessions.partial", this.root, warning);
|
|
3683
5159
|
} catch (e) {
|
|
3684
5160
|
report.issues.push(diagnostic(e));
|
|
3685
5161
|
}
|
|
@@ -3730,11 +5206,20 @@ var SyncJob = class {
|
|
|
3730
5206
|
);
|
|
3731
5207
|
}
|
|
3732
5208
|
this.lastIssue = issueKey;
|
|
3733
|
-
|
|
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);
|
|
3734
5219
|
this.runtime.save(this.root, {
|
|
3735
5220
|
state: this.stopped ? "stopped" : issue ? "waiting" : "idle",
|
|
3736
5221
|
checkedAt,
|
|
3737
|
-
lastSuccessAt: !issue && !pendingDownloads ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
5222
|
+
lastSuccessAt: !issue && !pendingDownloads && !report.pendingPublication ? checkedAt : prior?.lastSuccessAt ?? null,
|
|
3738
5223
|
pendingFiles,
|
|
3739
5224
|
pendingDownloads,
|
|
3740
5225
|
issue: issue || null
|
|
@@ -3780,7 +5265,7 @@ Use teammy status or teammy logs for diagnostics.
|
|
|
3780
5265
|
`;
|
|
3781
5266
|
async function runSyncCommand(args, version) {
|
|
3782
5267
|
const action = ["on", "off"].includes(args[0] ?? "") ? args[0] : void 0;
|
|
3783
|
-
const { values } =
|
|
5268
|
+
const { values } = parseArgs4({
|
|
3784
5269
|
args: action ? args.slice(1) : args,
|
|
3785
5270
|
options: {
|
|
3786
5271
|
...locationOptions,
|
|
@@ -3820,16 +5305,29 @@ async function runSyncCommand(args, version) {
|
|
|
3820
5305
|
"Timing and recovery overrides require --once or --foreground. Manage normal sync timing in the dashboard."
|
|
3821
5306
|
);
|
|
3822
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
|
+
});
|
|
3823
5312
|
const state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3824
5313
|
try {
|
|
3825
5314
|
if (!state.binding(root)) throw new Error("Connect this workspace first.");
|
|
3826
5315
|
if (action) {
|
|
3827
5316
|
if (action === "on") await verifyWorkspace(state, root);
|
|
3828
5317
|
runtime.enable(root, action === "on");
|
|
3829
|
-
if (action === "on")
|
|
3830
|
-
|
|
3831
|
-
|
|
3832
|
-
|
|
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
|
+
}
|
|
3833
5331
|
const status = runtime.status(root);
|
|
3834
5332
|
if (status) runtime.save(root, { ...status, state: "stopped" });
|
|
3835
5333
|
log.write("sync.off_requested", root);
|
|
@@ -3890,10 +5388,60 @@ async function runSyncCommand(args, version) {
|
|
|
3890
5388
|
}
|
|
3891
5389
|
}
|
|
3892
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
|
+
|
|
3893
5438
|
// apps/client/runtime/daemon.ts
|
|
3894
5439
|
import { createServer } from "node:net";
|
|
3895
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";
|
|
3896
5443
|
async function runDaemon(directory, version) {
|
|
5444
|
+
await prepareClient(directory, version, { daemonStartup: true });
|
|
3897
5445
|
const lock = acquireRootLock("daemon:" + directory, directory), state = new ClientState(directory), runtime = new RuntimeState(state), log = new ClientLog(directory);
|
|
3898
5446
|
const jobs = /* @__PURE__ */ new Map(), retry = /* @__PURE__ */ new Map();
|
|
3899
5447
|
let closing = false, serial = Promise.resolve();
|
|
@@ -3958,7 +5506,15 @@ async function runDaemon(directory, version) {
|
|
|
3958
5506
|
void (async () => {
|
|
3959
5507
|
const input = RequestSchema.parse(JSON.parse(body.split("\n")[0]));
|
|
3960
5508
|
if (closing) throw new Error("Background service is stopping.");
|
|
3961
|
-
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
|
+
};
|
|
3962
5518
|
if (input.method === "wake") {
|
|
3963
5519
|
retry.clear();
|
|
3964
5520
|
await reconcile();
|
|
@@ -3996,8 +5552,8 @@ async function runDaemon(directory, version) {
|
|
|
3996
5552
|
let timer;
|
|
3997
5553
|
let resolveStop = () => {
|
|
3998
5554
|
};
|
|
3999
|
-
const stopped = new Promise((
|
|
4000
|
-
resolveStop =
|
|
5555
|
+
const stopped = new Promise((resolve16) => {
|
|
5556
|
+
resolveStop = resolve16;
|
|
4001
5557
|
});
|
|
4002
5558
|
const stop = () => {
|
|
4003
5559
|
closing = true;
|
|
@@ -4008,14 +5564,15 @@ async function runDaemon(directory, version) {
|
|
|
4008
5564
|
await unlink4(socketPath(directory)).catch((e) => {
|
|
4009
5565
|
if (e.code !== "ENOENT") throw e;
|
|
4010
5566
|
});
|
|
4011
|
-
await new Promise((
|
|
5567
|
+
await new Promise((resolve16, reject) => {
|
|
4012
5568
|
server.once("error", reject);
|
|
4013
5569
|
server.listen(socketPath(directory), () => {
|
|
4014
5570
|
server.off("error", reject);
|
|
4015
|
-
|
|
5571
|
+
resolve16();
|
|
4016
5572
|
});
|
|
4017
5573
|
});
|
|
4018
5574
|
await chmod2(socketPath(directory), 384);
|
|
5575
|
+
writePrivateJson(join25(directory, "runtime", "daemon.json"), { pid: process.pid, directory });
|
|
4019
5576
|
process.once("SIGINT", stop);
|
|
4020
5577
|
process.once("SIGTERM", stop);
|
|
4021
5578
|
log.write("daemon.started", void 0, `version=${version}`);
|
|
@@ -4035,9 +5592,11 @@ async function runDaemon(directory, version) {
|
|
|
4035
5592
|
await Promise.all([...jobs.values(), ...temporaryJobs].map((job) => job.close()));
|
|
4036
5593
|
await Promise.allSettled(oneShots.values());
|
|
4037
5594
|
for (const socket of sockets) socket.destroy();
|
|
4038
|
-
await new Promise((
|
|
5595
|
+
await new Promise((resolve16) => server.close(() => resolve16()));
|
|
4039
5596
|
await unlink4(socketPath(directory)).catch(() => {
|
|
4040
5597
|
});
|
|
5598
|
+
await unlink4(join25(directory, "runtime", "daemon.json")).catch(() => {
|
|
5599
|
+
});
|
|
4041
5600
|
log.write("daemon.stopped");
|
|
4042
5601
|
state.close();
|
|
4043
5602
|
lock.close();
|
|
@@ -4054,10 +5613,12 @@ Usage:
|
|
|
4054
5613
|
teammy server-info --url URL Check server connection and protocol capabilities
|
|
4055
5614
|
teammy server-info --help Show connection options
|
|
4056
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
|
|
4057
5618
|
teammy status [--root DIR] [--state-dir DIR]
|
|
4058
5619
|
teammy sync on | off [--root DIR]
|
|
4059
5620
|
teammy logs [--root DIR] Show recent diagnostic events
|
|
4060
|
-
teammy session --help
|
|
5621
|
+
teammy session --help Collect, read and search the conversation library (\u5BF9\u8BDD\u5E93)
|
|
4061
5622
|
|
|
4062
5623
|
Root defaults to ./workspaces; local state defaults to ~/.teammy/client.
|
|
4063
5624
|
connect starts background sync; enabled projects resume after login.
|
|
@@ -4089,12 +5650,12 @@ async function runCli(args, version) {
|
|
|
4089
5650
|
return 1;
|
|
4090
5651
|
}
|
|
4091
5652
|
if (command === "_daemon") {
|
|
4092
|
-
const { values } =
|
|
5653
|
+
const { values } = parseArgs6({
|
|
4093
5654
|
args: args.slice(1),
|
|
4094
5655
|
options: { "state-dir": { type: "string" } }
|
|
4095
5656
|
});
|
|
4096
5657
|
if (!values["state-dir"]) throw new Error("Missing daemon state directory.");
|
|
4097
|
-
const directory =
|
|
5658
|
+
const directory = resolve15(values["state-dir"]);
|
|
4098
5659
|
try {
|
|
4099
5660
|
await runDaemon(directory, version);
|
|
4100
5661
|
return 0;
|
|
@@ -4103,9 +5664,17 @@ async function runCli(args, version) {
|
|
|
4103
5664
|
return 1;
|
|
4104
5665
|
}
|
|
4105
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
|
+
}
|
|
4106
5675
|
if (command === "session") {
|
|
4107
5676
|
try {
|
|
4108
|
-
return await runSessionCommand(args.slice(1));
|
|
5677
|
+
return await runSessionCommand(args.slice(1), version);
|
|
4109
5678
|
} catch (error) {
|
|
4110
5679
|
if (args[1] === "hook") return 0;
|
|
4111
5680
|
process.stderr.write(
|
|
@@ -4138,7 +5707,7 @@ async function runCli(args, version) {
|
|
|
4138
5707
|
return 1;
|
|
4139
5708
|
}
|
|
4140
5709
|
try {
|
|
4141
|
-
const { values } =
|
|
5710
|
+
const { values } = parseArgs6({
|
|
4142
5711
|
args: args.slice(1),
|
|
4143
5712
|
options: {
|
|
4144
5713
|
help: { type: "boolean", short: "h" },
|