@prjct.app/pi-team 0.6.1 → 0.7.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/CHANGELOG.md +65 -0
- package/CONTRIBUTING.md +2 -1
- package/README.md +23 -178
- package/docs/architecture.md +36 -173
- package/package.json +10 -4
- package/src/commands/team-command.ts +37 -0
- package/src/domain/lease.ts +54 -0
- package/src/domain/member.ts +58 -0
- package/src/domain/message.ts +91 -0
- package/src/domain/request.ts +67 -0
- package/src/domain/team.ts +71 -0
- package/src/dynamic/domain.ts +110 -0
- package/src/dynamic/memory.ts +38 -0
- package/src/dynamic/panel.ts +155 -0
- package/src/dynamic/peer-log.ts +39 -0
- package/src/dynamic/runner.ts +196 -0
- package/src/dynamic/service.ts +292 -0
- package/src/dynamic/store.ts +57 -0
- package/src/dynamic/view.ts +21 -0
- package/src/dynamic/worker.ts +210 -0
- package/src/dynamic/workspace.ts +43 -0
- package/src/index.ts +204 -679
- package/src/process-identity.ts +68 -0
- package/src/runtime/delivery.ts +326 -0
- package/src/runtime/membership.ts +212 -0
- package/src/runtime/presence.ts +98 -0
- package/src/runtime/purge.ts +39 -0
- package/src/runtime/reconciler.ts +112 -0
- package/src/runtime/requests.ts +353 -0
- package/src/runtime/resources.ts +117 -0
- package/src/runtime/team-runtime.ts +47 -0
- package/src/runtime/team-tool.ts +191 -0
- package/src/storage/atomic.ts +347 -0
- package/src/storage/inbox-store.ts +290 -0
- package/src/storage/lease-store.ts +158 -0
- package/src/storage/paths.ts +76 -0
- package/src/storage/receipt-store.ts +117 -0
- package/src/storage/team-store.ts +190 -0
- package/src/supervisor/control-protocol.ts +125 -0
- package/src/supervisor/runtime-store.ts +231 -0
- package/src/supervisor/shutdown.ts +141 -0
- package/src/supervisor/supervisor.ts +657 -0
- package/src/supervisor/tmux-adapter.ts +192 -0
- package/src/supervisor/worker-bootstrap.ts +43 -0
- package/src/supervisor/worker-client.ts +233 -0
- package/src/ui/team-dashboard.ts +179 -0
- package/src/mailbox.ts +0 -536
- package/src/schema.ts +0 -25
- package/src/store.ts +0 -247
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { EntityIdSchema, TeamIdSchema, TimestampSchema, timestampMillis } from './team.ts';
|
|
4
|
+
|
|
5
|
+
export type LeaseKind = 'presence' | 'delivery' | 'resource';
|
|
6
|
+
|
|
7
|
+
export type Lease = {
|
|
8
|
+
readonly schemaVersion: 2;
|
|
9
|
+
readonly leaseId: string;
|
|
10
|
+
readonly teamId: string;
|
|
11
|
+
readonly kind: LeaseKind;
|
|
12
|
+
readonly holderId: string;
|
|
13
|
+
readonly resourceId: string;
|
|
14
|
+
readonly token: string;
|
|
15
|
+
readonly generation: number;
|
|
16
|
+
readonly acquiredAt: string;
|
|
17
|
+
readonly expiresAt: string;
|
|
18
|
+
readonly releasedAt?: string;
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export const LeaseSchema = Type.Object({
|
|
22
|
+
schemaVersion: Type.Literal(2),
|
|
23
|
+
leaseId: EntityIdSchema,
|
|
24
|
+
teamId: TeamIdSchema,
|
|
25
|
+
kind: Type.Union([Type.Literal('presence'), Type.Literal('delivery'), Type.Literal('resource')]),
|
|
26
|
+
holderId: EntityIdSchema,
|
|
27
|
+
resourceId: Type.String({ minLength: 1, maxLength: 4096 }),
|
|
28
|
+
token: Type.String({ minLength: 32, maxLength: 256 }),
|
|
29
|
+
generation: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
30
|
+
acquiredAt: TimestampSchema,
|
|
31
|
+
expiresAt: TimestampSchema,
|
|
32
|
+
releasedAt: Type.Optional(TimestampSchema),
|
|
33
|
+
}, { additionalProperties: false });
|
|
34
|
+
|
|
35
|
+
export function assertLease(value: unknown): asserts value is Lease {
|
|
36
|
+
if (!Value.Check(LeaseSchema, value)) throw new Error('Invalid Team v2 lease record.');
|
|
37
|
+
const lease = value as Lease;
|
|
38
|
+
if (lease.resourceId.includes('\0')) throw new Error('Lease resource ID contains a null byte.');
|
|
39
|
+
const acquired = timestampMillis(lease.acquiredAt, 'lease acquiredAt');
|
|
40
|
+
if (timestampMillis(lease.expiresAt, 'lease expiresAt') <= acquired) {
|
|
41
|
+
throw new Error('Lease expiry must be after acquisition.');
|
|
42
|
+
}
|
|
43
|
+
if (lease.releasedAt !== undefined && timestampMillis(lease.releasedAt, 'lease releasedAt') < acquired) {
|
|
44
|
+
throw new Error('Lease release precedes acquisition.');
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function leaseExpired(lease: Lease, now = Date.now()): boolean {
|
|
49
|
+
return lease.releasedAt !== undefined || timestampMillis(lease.expiresAt, 'lease expiresAt') <= now;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function sameLeaseOwner(lease: Lease, token: string, generation: number): boolean {
|
|
53
|
+
return lease.token === token && lease.generation === generation;
|
|
54
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { isAbsolute } from 'node:path';
|
|
2
|
+
import { Type } from 'typebox';
|
|
3
|
+
import { Value } from 'typebox/value';
|
|
4
|
+
import { EntityIdSchema, TeamIdSchema, TimestampSchema, assertTeamId, timestampMillis, type Team } from './team.ts';
|
|
5
|
+
|
|
6
|
+
export type MemberKind = 'external' | 'supervised';
|
|
7
|
+
export type MemberState = 'active' | 'left';
|
|
8
|
+
|
|
9
|
+
export type Member = {
|
|
10
|
+
readonly schemaVersion: 2;
|
|
11
|
+
readonly teamId: string;
|
|
12
|
+
readonly memberId: string;
|
|
13
|
+
readonly sessionId?: string;
|
|
14
|
+
readonly alias: string;
|
|
15
|
+
readonly kind: MemberKind;
|
|
16
|
+
readonly generation: number;
|
|
17
|
+
readonly state: MemberState;
|
|
18
|
+
readonly cwd: string;
|
|
19
|
+
readonly joinedAt: string;
|
|
20
|
+
readonly updatedAt: string;
|
|
21
|
+
readonly leftAt?: string;
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
export const MemberSchema = Type.Object({
|
|
25
|
+
schemaVersion: Type.Literal(2),
|
|
26
|
+
teamId: TeamIdSchema,
|
|
27
|
+
memberId: EntityIdSchema,
|
|
28
|
+
sessionId: Type.Optional(EntityIdSchema),
|
|
29
|
+
alias: TeamIdSchema,
|
|
30
|
+
kind: Type.Union([Type.Literal('external'), Type.Literal('supervised')]),
|
|
31
|
+
generation: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
32
|
+
state: Type.Union([Type.Literal('active'), Type.Literal('left')]),
|
|
33
|
+
cwd: Type.String({ minLength: 1, maxLength: 4096 }),
|
|
34
|
+
joinedAt: TimestampSchema,
|
|
35
|
+
updatedAt: TimestampSchema,
|
|
36
|
+
leftAt: Type.Optional(TimestampSchema),
|
|
37
|
+
}, { additionalProperties: false });
|
|
38
|
+
|
|
39
|
+
export function assertMember(value: unknown): asserts value is Member {
|
|
40
|
+
if (!Value.Check(MemberSchema, value)) throw new Error('Invalid Team v2 member record.');
|
|
41
|
+
const member = value as Member;
|
|
42
|
+
assertTeamId(member.alias);
|
|
43
|
+
if (member.cwd.includes('\0') || !isAbsolute(member.cwd)) throw new Error('Member cwd must be an absolute path without null bytes.');
|
|
44
|
+
const joined = timestampMillis(member.joinedAt, 'member joinedAt');
|
|
45
|
+
const updated = timestampMillis(member.updatedAt, 'member updatedAt');
|
|
46
|
+
if (updated < joined) throw new Error('Member updatedAt precedes joinedAt.');
|
|
47
|
+
if ((member.state === 'left') !== (member.leftAt !== undefined)) {
|
|
48
|
+
throw new Error('Only a member in the left state has leftAt.');
|
|
49
|
+
}
|
|
50
|
+
if (member.leftAt !== undefined) {
|
|
51
|
+
const left = timestampMillis(member.leftAt, 'member leftAt');
|
|
52
|
+
if (left < joined || left > updated) throw new Error('Member leftAt falls outside its membership lifetime.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function memberBelongsToTeam(member: Member, team: Pick<Team, 'teamId'>): void {
|
|
57
|
+
if (member.teamId !== team.teamId) throw new Error('Member team ID does not match its team.');
|
|
58
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { EntityIdSchema, TeamIdSchema, TimestampSchema, timestampMillis } from './team.ts';
|
|
4
|
+
|
|
5
|
+
export const MAX_MESSAGE_BODY_BYTES = 8 * 1024;
|
|
6
|
+
export const MAX_MESSAGE_TTL_MS = 24 * 60 * 60 * 1000;
|
|
7
|
+
|
|
8
|
+
export type MessageKind = 'info' | 'question' | 'proposal' | 'handoff' | 'blocker' | 'request' | 'reply' | 'cancel';
|
|
9
|
+
export type ReceiptStatus = 'delivered' | 'read' | 'replied' | 'cancelled' | 'expired' | 'failed';
|
|
10
|
+
|
|
11
|
+
export type Receipt = {
|
|
12
|
+
readonly schemaVersion: 2;
|
|
13
|
+
readonly teamId: string;
|
|
14
|
+
readonly messageId: string;
|
|
15
|
+
readonly recipientId: string;
|
|
16
|
+
readonly status: ReceiptStatus;
|
|
17
|
+
readonly at: string;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const ReceiptSchema = Type.Object({
|
|
21
|
+
schemaVersion: Type.Literal(2),
|
|
22
|
+
teamId: TeamIdSchema,
|
|
23
|
+
messageId: EntityIdSchema,
|
|
24
|
+
recipientId: EntityIdSchema,
|
|
25
|
+
status: Type.Union([
|
|
26
|
+
Type.Literal('delivered'), Type.Literal('read'), Type.Literal('replied'), Type.Literal('cancelled'),
|
|
27
|
+
Type.Literal('expired'), Type.Literal('failed'),
|
|
28
|
+
]),
|
|
29
|
+
at: TimestampSchema,
|
|
30
|
+
}, { additionalProperties: false });
|
|
31
|
+
|
|
32
|
+
export function assertReceipt(value: unknown): asserts value is Receipt {
|
|
33
|
+
if (!Value.Check(ReceiptSchema, value)) throw new Error('Invalid Team v2 receipt record.');
|
|
34
|
+
timestampMillis((value as Receipt).at, 'receipt timestamp');
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export type Envelope = {
|
|
38
|
+
readonly schemaVersion: 2;
|
|
39
|
+
readonly messageId: string;
|
|
40
|
+
readonly teamId: string;
|
|
41
|
+
readonly threadId: string;
|
|
42
|
+
readonly requestId?: string;
|
|
43
|
+
readonly kind: MessageKind;
|
|
44
|
+
readonly fromMemberId: string;
|
|
45
|
+
readonly toMemberId: string;
|
|
46
|
+
readonly senderGeneration: number;
|
|
47
|
+
readonly recipientGeneration?: number;
|
|
48
|
+
readonly createdAt: string;
|
|
49
|
+
readonly expiresAt: string;
|
|
50
|
+
readonly body: string;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
export const EnvelopeSchema = Type.Object({
|
|
54
|
+
schemaVersion: Type.Literal(2),
|
|
55
|
+
messageId: EntityIdSchema,
|
|
56
|
+
teamId: TeamIdSchema,
|
|
57
|
+
threadId: EntityIdSchema,
|
|
58
|
+
requestId: Type.Optional(EntityIdSchema),
|
|
59
|
+
kind: Type.Union([
|
|
60
|
+
Type.Literal('info'), Type.Literal('question'), Type.Literal('proposal'), Type.Literal('handoff'),
|
|
61
|
+
Type.Literal('blocker'), Type.Literal('request'), Type.Literal('reply'), Type.Literal('cancel'),
|
|
62
|
+
]),
|
|
63
|
+
fromMemberId: EntityIdSchema,
|
|
64
|
+
toMemberId: EntityIdSchema,
|
|
65
|
+
senderGeneration: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
66
|
+
recipientGeneration: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })),
|
|
67
|
+
createdAt: TimestampSchema,
|
|
68
|
+
expiresAt: TimestampSchema,
|
|
69
|
+
body: Type.String({ maxLength: MAX_MESSAGE_BODY_BYTES }),
|
|
70
|
+
}, { additionalProperties: false });
|
|
71
|
+
|
|
72
|
+
export function assertEnvelope(value: unknown): asserts value is Envelope {
|
|
73
|
+
if (!Value.Check(EnvelopeSchema, value)) throw new Error('Invalid Team v2 message envelope.');
|
|
74
|
+
const message = value as Envelope;
|
|
75
|
+
if (Buffer.byteLength(message.body, 'utf8') > MAX_MESSAGE_BODY_BYTES) {
|
|
76
|
+
throw new Error(`Message body exceeds ${MAX_MESSAGE_BODY_BYTES} bytes.`);
|
|
77
|
+
}
|
|
78
|
+
const created = timestampMillis(message.createdAt, 'message createdAt');
|
|
79
|
+
const expires = timestampMillis(message.expiresAt, 'message expiresAt');
|
|
80
|
+
if (expires <= created || expires - created > MAX_MESSAGE_TTL_MS) {
|
|
81
|
+
throw new Error('Message expiry must be after creation and within 24 hours.');
|
|
82
|
+
}
|
|
83
|
+
const correlated = ['request', 'reply', 'cancel'].includes(message.kind);
|
|
84
|
+
if (correlated !== (message.requestId !== undefined)) {
|
|
85
|
+
throw new Error('Request, reply, and cancel messages require requestId; other message kinds must omit it.');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export function messageExpired(message: Envelope, now = Date.now()): boolean {
|
|
90
|
+
return timestampMillis(message.expiresAt, 'message expiresAt') <= now;
|
|
91
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { MAX_MESSAGE_TTL_MS } from './message.ts';
|
|
4
|
+
import { EntityIdSchema, TeamIdSchema, TimestampSchema, timestampMillis } from './team.ts';
|
|
5
|
+
|
|
6
|
+
export type RequestState = 'queued' | 'delivered' | 'accepted' | 'replied' | 'cancelled' | 'expired';
|
|
7
|
+
|
|
8
|
+
export type Request = {
|
|
9
|
+
readonly schemaVersion: 2;
|
|
10
|
+
readonly requestId: string;
|
|
11
|
+
readonly messageId: string;
|
|
12
|
+
readonly teamId: string;
|
|
13
|
+
readonly senderMemberId: string;
|
|
14
|
+
readonly recipientMemberId: string;
|
|
15
|
+
readonly senderGeneration: number;
|
|
16
|
+
readonly recipientGeneration?: number;
|
|
17
|
+
readonly state: RequestState;
|
|
18
|
+
readonly createdAt: string;
|
|
19
|
+
readonly updatedAt: string;
|
|
20
|
+
readonly expiresAt: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
export const RequestSchema = Type.Object({
|
|
24
|
+
schemaVersion: Type.Literal(2),
|
|
25
|
+
requestId: EntityIdSchema,
|
|
26
|
+
messageId: EntityIdSchema,
|
|
27
|
+
teamId: TeamIdSchema,
|
|
28
|
+
senderMemberId: EntityIdSchema,
|
|
29
|
+
recipientMemberId: EntityIdSchema,
|
|
30
|
+
senderGeneration: Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER }),
|
|
31
|
+
recipientGeneration: Type.Optional(Type.Integer({ minimum: 1, maximum: Number.MAX_SAFE_INTEGER })),
|
|
32
|
+
state: Type.Union([
|
|
33
|
+
Type.Literal('queued'), Type.Literal('delivered'), Type.Literal('accepted'), Type.Literal('replied'),
|
|
34
|
+
Type.Literal('cancelled'), Type.Literal('expired'),
|
|
35
|
+
]),
|
|
36
|
+
createdAt: TimestampSchema,
|
|
37
|
+
updatedAt: TimestampSchema,
|
|
38
|
+
expiresAt: TimestampSchema,
|
|
39
|
+
}, { additionalProperties: false });
|
|
40
|
+
|
|
41
|
+
const transitions: Readonly<Record<RequestState, readonly RequestState[]>> = {
|
|
42
|
+
queued: ['delivered', 'cancelled', 'expired'],
|
|
43
|
+
delivered: ['accepted', 'cancelled', 'expired'],
|
|
44
|
+
accepted: ['replied', 'cancelled', 'expired'],
|
|
45
|
+
replied: [],
|
|
46
|
+
cancelled: [],
|
|
47
|
+
expired: [],
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
export function assertRequest(value: unknown): asserts value is Request {
|
|
51
|
+
if (!Value.Check(RequestSchema, value)) throw new Error('Invalid Team v2 request record.');
|
|
52
|
+
const request = value as Request;
|
|
53
|
+
const created = timestampMillis(request.createdAt, 'request createdAt');
|
|
54
|
+
const updated = timestampMillis(request.updatedAt, 'request updatedAt');
|
|
55
|
+
const expires = timestampMillis(request.expiresAt, 'request expiresAt');
|
|
56
|
+
if (updated < created || expires <= created || expires - created > MAX_MESSAGE_TTL_MS) {
|
|
57
|
+
throw new Error('Invalid request timestamp ordering or TTL.');
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assertRequestTransition(from: RequestState, to: RequestState): void {
|
|
62
|
+
if (!transitions[from].includes(to)) throw new Error(`Invalid request transition: ${from} → ${to}.`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function requestTerminal(state: RequestState): boolean {
|
|
66
|
+
return transitions[state].length === 0;
|
|
67
|
+
}
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { Type } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
|
|
4
|
+
export const TEAM_ID_PATTERN = /^[a-z][a-z0-9-]{0,47}$/;
|
|
5
|
+
export const ENTITY_ID_PATTERN = /^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$/;
|
|
6
|
+
export const ISO_TIMESTAMP_PATTERN = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/;
|
|
7
|
+
|
|
8
|
+
export const TeamIdSchema = Type.String({ pattern: TEAM_ID_PATTERN.source });
|
|
9
|
+
export const EntityIdSchema = Type.String({ pattern: ENTITY_ID_PATTERN.source });
|
|
10
|
+
export const TimestampSchema = Type.String({ pattern: ISO_TIMESTAMP_PATTERN.source });
|
|
11
|
+
|
|
12
|
+
export type TeamState = 'open' | 'closing' | 'closing_blocked' | 'closed';
|
|
13
|
+
|
|
14
|
+
const transitions: Readonly<Record<TeamState, readonly TeamState[]>> = {
|
|
15
|
+
open: ['closing'],
|
|
16
|
+
closing: ['closing_blocked', 'closed'],
|
|
17
|
+
closing_blocked: ['closing', 'closed'],
|
|
18
|
+
closed: [],
|
|
19
|
+
};
|
|
20
|
+
|
|
21
|
+
export type Team = {
|
|
22
|
+
readonly schemaVersion: 2;
|
|
23
|
+
readonly teamId: string;
|
|
24
|
+
readonly state: TeamState;
|
|
25
|
+
readonly createdAt: string;
|
|
26
|
+
readonly updatedAt: string;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
export const TeamSchema = Type.Object({
|
|
30
|
+
schemaVersion: Type.Literal(2),
|
|
31
|
+
teamId: TeamIdSchema,
|
|
32
|
+
state: Type.Union([
|
|
33
|
+
Type.Literal('open'),
|
|
34
|
+
Type.Literal('closing'),
|
|
35
|
+
Type.Literal('closing_blocked'),
|
|
36
|
+
Type.Literal('closed'),
|
|
37
|
+
]),
|
|
38
|
+
createdAt: TimestampSchema,
|
|
39
|
+
updatedAt: TimestampSchema,
|
|
40
|
+
}, { additionalProperties: false });
|
|
41
|
+
|
|
42
|
+
export function assertTeamId(value: string): string {
|
|
43
|
+
if (!TEAM_ID_PATTERN.test(value)) {
|
|
44
|
+
throw new Error('Invalid team ID. Use 1–48 lowercase letters, digits, or hyphens, starting with a letter.');
|
|
45
|
+
}
|
|
46
|
+
return value;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function assertEntityId(value: string, label = 'entity ID'): string {
|
|
50
|
+
if (!ENTITY_ID_PATTERN.test(value)) throw new Error(`Invalid ${label}.`);
|
|
51
|
+
return value;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
export function timestampMillis(value: string, label = 'timestamp'): number {
|
|
55
|
+
if (!ISO_TIMESTAMP_PATTERN.test(value)) throw new Error(`Invalid ${label}.`);
|
|
56
|
+
const millis = Date.parse(value);
|
|
57
|
+
if (!Number.isFinite(millis) || new Date(millis).toISOString() !== value) throw new Error(`Invalid ${label}.`);
|
|
58
|
+
return millis;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function assertTeam(value: unknown): asserts value is Team {
|
|
62
|
+
if (!Value.Check(TeamSchema, value)) throw new Error('Invalid Team v2 record.');
|
|
63
|
+
const team = value as Team;
|
|
64
|
+
if (timestampMillis(team.updatedAt, 'team updatedAt') < timestampMillis(team.createdAt, 'team createdAt')) {
|
|
65
|
+
throw new Error('Team updatedAt precedes createdAt.');
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function assertTeamTransition(from: TeamState, to: TeamState): void {
|
|
70
|
+
if (from !== to && !transitions[from].includes(to)) throw new Error(`Invalid team transition: ${from} → ${to}.`);
|
|
71
|
+
}
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
import { Type, type Static } from 'typebox';
|
|
2
|
+
import { Value } from 'typebox/value';
|
|
3
|
+
import { StringEnum } from '@earendil-works/pi-ai';
|
|
4
|
+
import { EntityIdSchema, TimestampSchema } from '../domain/team.ts';
|
|
5
|
+
|
|
6
|
+
export const LIMITS = { runs: 64, assignments: 128, experts: 16, history: 32, concurrent: 3, recordBytes: 2 * 1024 * 1024 } as const;
|
|
7
|
+
const text = (maxLength: number) => Type.String({ maxLength });
|
|
8
|
+
const strict = { additionalProperties: false } as const;
|
|
9
|
+
const tag = Type.String({ pattern: '^[a-z][a-z0-9-]{0,31}$' });
|
|
10
|
+
export const PolicySchema = Type.Object({
|
|
11
|
+
tools: Type.Array(StringEnum(['read', 'grep', 'find', 'ls', 'edit', 'write', 'bash']), { maxItems: 7, uniqueItems: true }),
|
|
12
|
+
}, strict);
|
|
13
|
+
export const DispatchSchema = Type.Object({
|
|
14
|
+
role: text(64), capabilities: Type.Array(text(64), { maxItems: 16, minItems: 1 }),
|
|
15
|
+
task: text(8192), instructions: Type.Optional(text(4096)), policy: PolicySchema,
|
|
16
|
+
}, strict);
|
|
17
|
+
export type Dispatch = Static<typeof DispatchSchema>;
|
|
18
|
+
export type Policy = Static<typeof PolicySchema>;
|
|
19
|
+
export const ExpertSchema = Type.Object({
|
|
20
|
+
id: EntityIdSchema, role: tag, capabilities: Type.Array(tag, { maxItems: 16, uniqueItems: true }),
|
|
21
|
+
instructions: text(4096), policy: PolicySchema, sessionRef: EntityIdSchema,
|
|
22
|
+
generation: Type.Integer({ minimum: 0 }), status: StringEnum(['idle', 'busy', 'blocked']),
|
|
23
|
+
memory: text(4096), history: Type.Array(EntityIdSchema, { maxItems: LIMITS.history }),
|
|
24
|
+
createdAt: TimestampSchema, updatedAt: TimestampSchema,
|
|
25
|
+
}, strict);
|
|
26
|
+
export const RunSchema = Type.Object({
|
|
27
|
+
id: EntityIdSchema, objective: text(8192), summary: text(4096),
|
|
28
|
+
status: StringEnum(['queued', 'active', 'completed', 'cancelled', 'interrupted']),
|
|
29
|
+
createdAt: TimestampSchema, updatedAt: TimestampSchema, startedAt: Type.Optional(TimestampSchema), endedAt: Type.Optional(TimestampSchema),
|
|
30
|
+
}, strict);
|
|
31
|
+
export const AssignmentSchema = Type.Object({
|
|
32
|
+
id: EntityIdSchema, runId: EntityIdSchema, expertId: EntityIdSchema,
|
|
33
|
+
generation: Type.Integer({ minimum: 0 }), ownerEpoch: Type.Integer({ minimum: 1 }),
|
|
34
|
+
status: StringEnum(['queued', 'running', 'completed', 'failed', 'cancelled', 'cancelled_waiting']),
|
|
35
|
+
task: text(8192), result: text(4096), error: text(512),
|
|
36
|
+
createdAt: TimestampSchema, updatedAt: TimestampSchema, endedAt: Type.Optional(TimestampSchema),
|
|
37
|
+
}, strict);
|
|
38
|
+
export const OwnerSchema = Type.Object({
|
|
39
|
+
sessionId: EntityIdSchema, instanceId: EntityIdSchema, epoch: Type.Integer({ minimum: 1 }),
|
|
40
|
+
processPid: Type.Integer({ minimum: 2 }), processGroupId: Type.Integer({ minimum: 2 }), processStartToken: text(256),
|
|
41
|
+
}, strict);
|
|
42
|
+
export const StateSchema = Type.Object({
|
|
43
|
+
schemaVersion: Type.Literal(1), teamId: Type.String({ pattern: '^p-[a-f0-9]{40}$' }), projectPath: text(4096),
|
|
44
|
+
epoch: Type.Integer({ minimum: 0 }), owner: Type.Optional(OwnerSchema),
|
|
45
|
+
orchestrator: Type.Object({ summary: text(4096), lastSessionId: Type.Optional(EntityIdSchema) }, strict),
|
|
46
|
+
experts: Type.Array(ExpertSchema, { maxItems: LIMITS.experts }),
|
|
47
|
+
runs: Type.Array(RunSchema, { maxItems: LIMITS.runs }),
|
|
48
|
+
assignments: Type.Array(AssignmentSchema, { maxItems: LIMITS.assignments }),
|
|
49
|
+
createdAt: TimestampSchema, updatedAt: TimestampSchema,
|
|
50
|
+
}, strict);
|
|
51
|
+
export type Expert = Static<typeof ExpertSchema>;
|
|
52
|
+
export type Run = Static<typeof RunSchema>;
|
|
53
|
+
export type Assignment = Static<typeof AssignmentSchema>;
|
|
54
|
+
export type Owner = Static<typeof OwnerSchema>;
|
|
55
|
+
export type TeamState = Static<typeof StateSchema>;
|
|
56
|
+
export const terminalRun = (run: Run): boolean => !['active', 'queued'].includes(run.status);
|
|
57
|
+
export const terminalAssignment = (assignment: Assignment): boolean => !['running', 'queued'].includes(assignment.status);
|
|
58
|
+
|
|
59
|
+
export function bounded(value: string, bytes: number, label = 'text'): string {
|
|
60
|
+
if (Buffer.byteLength(value, 'utf8') > bytes) throw new Error(`${label} exceeds ${bytes} UTF-8 bytes.`);
|
|
61
|
+
return value;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Metadata must never contain credentials. This is best-effort redaction, not a secret detector.
|
|
65
|
+
export function metadata(value: string, bytes = 4096): string {
|
|
66
|
+
const safe = value.replace(/\x1b\][^\x07]*(?:\x07|\x1b\\)/g, '')
|
|
67
|
+
.replace(/\x1b\[[0-?]*[ -/]*[@-~]/g, '')
|
|
68
|
+
.replace(/[\x00-\x08\x0b-\x1f\x7f-\x9f\u202a-\u202e\u2066-\u2069]/g, '')
|
|
69
|
+
.replace(/\b(?:sk-[A-Za-z0-9_-]{8,}|Bearer\s+[^\s]+|[a-f0-9]{64})\b/gi, '[redacted]')
|
|
70
|
+
.replace(/\b(password|secret|token|api[_-]?key)\s*[:=]\s*[^\s,;]+/gi, '$1=[redacted]');
|
|
71
|
+
const chars: string[] = [];
|
|
72
|
+
const size = { value: 0 };
|
|
73
|
+
for (const char of safe) {
|
|
74
|
+
size.value += Buffer.byteLength(char, 'utf8');
|
|
75
|
+
if (size.value > bytes) break;
|
|
76
|
+
chars.push(char);
|
|
77
|
+
}
|
|
78
|
+
return chars.join('');
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function normalizeTag(value: string): string {
|
|
82
|
+
const normalized = value.trim().toLowerCase().replace(/[\s_]+/g, '-');
|
|
83
|
+
if (!/^[a-z][a-z0-9-]{0,31}$/.test(normalized)) throw new Error('Role/capability must normalize to a short lowercase tag.');
|
|
84
|
+
return normalized;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function assertState(value: unknown): asserts value is TeamState {
|
|
88
|
+
if (!Value.Check(StateSchema, value)) throw new Error('Invalid dynamic Team record.');
|
|
89
|
+
const state = value as TeamState;
|
|
90
|
+
bounded(state.projectPath, 4096);
|
|
91
|
+
bounded(state.orchestrator.summary, 4096);
|
|
92
|
+
for (const expert of state.experts) { bounded(expert.instructions, 4096); bounded(expert.memory, 4096); }
|
|
93
|
+
for (const run of state.runs) { bounded(run.objective, 8192); bounded(run.summary, 4096); }
|
|
94
|
+
for (const a of state.assignments) { bounded(a.task, 8192); bounded(a.result, 4096); bounded(a.error, 512); }
|
|
95
|
+
if (state.runs.filter(run => run.status === 'active').length > 1) throw new Error('Multiple active Runs.');
|
|
96
|
+
for (const values of [state.runs, state.experts, state.assignments]) {
|
|
97
|
+
if (new Set(values.map(value => value.id)).size !== values.length) throw new Error('Duplicate record identity.');
|
|
98
|
+
}
|
|
99
|
+
if (new Set(state.experts.map(expert => expert.role)).size !== state.experts.length) throw new Error('Duplicate role capacity.');
|
|
100
|
+
if (state.assignments.filter(a => a.status === 'running').length > LIMITS.concurrent) throw new Error('Concurrency limit exceeded.');
|
|
101
|
+
for (const a of state.assignments) {
|
|
102
|
+
const expert = state.experts.find(e => e.id === a.expertId);
|
|
103
|
+
const run = state.runs.find(r => r.id === a.runId);
|
|
104
|
+
if (!expert || !run) throw new Error('Dangling assignment.');
|
|
105
|
+
if (a.status === 'running' && (run.status !== 'active' || a.generation !== expert.generation || expert.status !== 'busy')) throw new Error('Invalid active assignment.');
|
|
106
|
+
}
|
|
107
|
+
for (const expert of state.experts) {
|
|
108
|
+
if (state.assignments.filter(a => a.expertId === expert.id && a.status === 'running').length > 1) throw new Error('Expert is double booked.');
|
|
109
|
+
}
|
|
110
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Project memory for an Expert, read by the orchestrator's process.
|
|
3
|
+
*
|
|
4
|
+
* An Expert starts without extensions, so it cannot open memory itself.
|
|
5
|
+
* pi-memory publishes a read-only view on a well-known process symbol that
|
|
6
|
+
* filters by stance: facts about the terrain reach everyone, decisions reach
|
|
7
|
+
* only those who must obey them, and a reviewer gets rules and procedures only
|
|
8
|
+
* so it judges from scratch. Absent pi-memory, an Expert simply starts without.
|
|
9
|
+
*/
|
|
10
|
+
export type ExpertStance = 'worker' | 'explorer' | 'reviewer';
|
|
11
|
+
|
|
12
|
+
const WRITE_TOOLS = ['edit', 'write', 'bash'];
|
|
13
|
+
const REVIEWING = /review|qa|audit|verif|critic|check|test/;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Roles are free-form, so the stance is read from what the Expert may do.
|
|
17
|
+
* A writer is a worker. A reader named for judging is a reviewer. Any other
|
|
18
|
+
* reader is an explorer. Unsure falls to the stricter side: a reader.
|
|
19
|
+
*/
|
|
20
|
+
export function expertStance(role: string, tools: readonly string[]): ExpertStance {
|
|
21
|
+
if (tools.some(tool => WRITE_TOOLS.includes(tool))) return 'worker';
|
|
22
|
+
return REVIEWING.test(role.toLowerCase()) ? 'reviewer' : 'explorer';
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
type ChildMemoryView = (request: { role: ExpertStance; query?: string }) => Promise<{ text: string }>;
|
|
26
|
+
const MEMORY_KEY = Symbol.for('prjct.memory');
|
|
27
|
+
const DEADLINE_MS = 5_000;
|
|
28
|
+
export const MAX_MEMORY_BYTES = 4096;
|
|
29
|
+
|
|
30
|
+
/** The rendered memory for a stance and a task; '' when there is none, it fails, or it is slow. */
|
|
31
|
+
export async function expertMemory(stance: ExpertStance, query: string): Promise<string> {
|
|
32
|
+
const host = (globalThis as unknown as Record<symbol, { childView?: ChildMemoryView } | undefined>)[MEMORY_KEY];
|
|
33
|
+
if (typeof host?.childView !== 'function') return '';
|
|
34
|
+
const timeout = new Promise<string>(resolve => { setTimeout(resolve, DEADLINE_MS, '').unref?.(); });
|
|
35
|
+
const text = await Promise.race([host.childView({ role: stance, query: query.slice(0, 1000) })
|
|
36
|
+
.then(view => view.text).catch(() => ''), timeout]);
|
|
37
|
+
return Buffer.byteLength(text, 'utf8') <= MAX_MEMORY_BYTES ? text : '';
|
|
38
|
+
}
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { SYMBOL, ago, type PanelAction, type PanelItem, type PanelSpec, type Tone } from '@prjct.app/pi-tui-kit';
|
|
2
|
+
import { LIMITS, metadata, type Assignment, type Expert, type Run, type TeamState } from './domain.ts';
|
|
3
|
+
import { peerLine, type PeerEntry } from './peer-log.ts';
|
|
4
|
+
|
|
5
|
+
/** What the /team panel can ask of the extension. */
|
|
6
|
+
export type TeamOps = Readonly<{
|
|
7
|
+
load(): Promise<TeamState | undefined>;
|
|
8
|
+
/** Whether this session owns the project Team and may cancel Runs. */
|
|
9
|
+
isOwner(): boolean;
|
|
10
|
+
cancel(runId: string): Promise<string>;
|
|
11
|
+
/** Close the panel and put "/team " in the editor for a new objective. */
|
|
12
|
+
compose(): void;
|
|
13
|
+
/** Direct Expert-to-Expert messages, newest last. */
|
|
14
|
+
messages?(): Promise<readonly PeerEntry[]>;
|
|
15
|
+
}>;
|
|
16
|
+
|
|
17
|
+
const TEAM = 'team';
|
|
18
|
+
const RUN = 'run:';
|
|
19
|
+
const EXPERT = 'expert:';
|
|
20
|
+
const one = (text: string, max = 160): string => metadata(text, max).replace(/\s+/g, ' ').trim();
|
|
21
|
+
const time = (iso: string | undefined): number | undefined => {
|
|
22
|
+
const parsed = iso ? Date.parse(iso) : Number.NaN;
|
|
23
|
+
return Number.isFinite(parsed) ? parsed : undefined;
|
|
24
|
+
};
|
|
25
|
+
const RUN_TONE: Record<Run['status'], [string, Tone]> = {
|
|
26
|
+
active: [SYMBOL.active, 'accent'], queued: [SYMBOL.idle, 'muted'], completed: [SYMBOL.ok, 'success'],
|
|
27
|
+
cancelled: [SYMBOL.idle, 'dim'], interrupted: [SYMBOL.attention, 'warning'],
|
|
28
|
+
};
|
|
29
|
+
const EXPERT_TONE: Record<Expert['status'], [string, Tone]> = {
|
|
30
|
+
busy: [SYMBOL.active, 'accent'], idle: [SYMBOL.idle, 'muted'], blocked: [SYMBOL.attention, 'warning'],
|
|
31
|
+
};
|
|
32
|
+
const ASSIGNMENT_MARK: Record<Assignment['status'], string> = {
|
|
33
|
+
queued: SYMBOL.idle, running: SYMBOL.active, completed: SYMBOL.ok, failed: SYMBOL.error, cancelled: SYMBOL.idle, cancelled_waiting: SYMBOL.attention,
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
/** Runs (newest first) and Experts, with every assignment traceable from both sides. */
|
|
37
|
+
export function teamPanelSpec(ops: TeamOps, initial: TeamState | undefined): PanelSpec {
|
|
38
|
+
const state = { team: initial, talk: [] as readonly PeerEntry[] };
|
|
39
|
+
const listeners = new Set<() => void>();
|
|
40
|
+
const reload = async (): Promise<void> => {
|
|
41
|
+
state.team = await ops.load();
|
|
42
|
+
state.talk = await ops.messages?.() ?? [];
|
|
43
|
+
for (const listener of listeners) listener();
|
|
44
|
+
};
|
|
45
|
+
void reload().catch(() => undefined);
|
|
46
|
+
const run = (item: PanelItem | undefined) => item?.id.startsWith(RUN) ? state.team?.runs.find(entry => entry.id === item.id.slice(RUN.length)) : undefined;
|
|
47
|
+
const expert = (id: string) => state.team?.experts.find(entry => entry.id === id);
|
|
48
|
+
const assignmentLine = (assignment: Assignment, by: 'run' | 'expert'): string => {
|
|
49
|
+
const who = by === 'run' ? expert(assignment.expertId)?.role ?? assignment.expertId : state.team?.runs.find(entry => entry.id === assignment.runId)?.id ?? assignment.runId;
|
|
50
|
+
const outcome = assignment.error ? ` · ${one(assignment.error, 80)}` : assignment.result ? ` · ${one(assignment.result, 80)}` : '';
|
|
51
|
+
return `${ASSIGNMENT_MARK[assignment.status]} ${assignment.status.padEnd(9)} ${one(who, 32)} · ${one(assignment.task, 60)}${outcome}`;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const actions: PanelAction[] = [
|
|
55
|
+
{ key: 'n', label: 'New objective', run: (_item, panel) => { panel.close(); ops.compose(); } },
|
|
56
|
+
{
|
|
57
|
+
key: 'c', label: 'Cancel run', confirm: true,
|
|
58
|
+
when: item => { const found = run(item); return !!found && ['active', 'queued'].includes(found.status) && ops.isOwner(); },
|
|
59
|
+
run: async (item, panel) => { const text = await ops.cancel(run(item)!.id); await reload(); panel.notice(text, 'success'); },
|
|
60
|
+
},
|
|
61
|
+
];
|
|
62
|
+
|
|
63
|
+
return {
|
|
64
|
+
title: 'Team',
|
|
65
|
+
summary: () => {
|
|
66
|
+
const team = state.team;
|
|
67
|
+
if (!team) return 'no Team yet';
|
|
68
|
+
const active = team.runs.filter(entry => entry.status === 'active').length;
|
|
69
|
+
const queued = team.runs.filter(entry => entry.status === 'queued').length;
|
|
70
|
+
const busy = team.experts.filter(entry => entry.status === 'busy').length;
|
|
71
|
+
return `${active} active · ${queued} queued · ${busy}/${team.experts.length} experts busy`;
|
|
72
|
+
},
|
|
73
|
+
items: () => {
|
|
74
|
+
const team = state.team;
|
|
75
|
+
if (!team) return [];
|
|
76
|
+
const blocked = team.experts.filter(entry => entry.status === 'blocked').length;
|
|
77
|
+
return [
|
|
78
|
+
{ id: TEAM, label: 'team', symbol: blocked ? SYMBOL.attention : SYMBOL.active, tone: blocked ? 'warning' : 'success', meta: team.owner ? 'owned' : 'no owner' },
|
|
79
|
+
...[...team.runs].reverse().map((entry): PanelItem => {
|
|
80
|
+
const [symbol, tone] = RUN_TONE[entry.status];
|
|
81
|
+
return { id: `${RUN}${entry.id}`, label: one(entry.objective, 60), symbol, tone, meta: `${entry.status} · ${ago(time(entry.updatedAt))}`, search: entry.id };
|
|
82
|
+
}),
|
|
83
|
+
...team.experts.map((entry): PanelItem => {
|
|
84
|
+
const [symbol, tone] = EXPERT_TONE[entry.status];
|
|
85
|
+
return { id: `${EXPERT}${entry.id}`, label: `expert · ${one(entry.role, 40)}`, symbol, tone, meta: entry.status, search: entry.capabilities.join(' ') };
|
|
86
|
+
}),
|
|
87
|
+
];
|
|
88
|
+
},
|
|
89
|
+
detail: item => {
|
|
90
|
+
const team = state.team!;
|
|
91
|
+
const found = run(item);
|
|
92
|
+
if (found) {
|
|
93
|
+
const work = team.assignments.filter(entry => entry.runId === found.id).reverse();
|
|
94
|
+
return {
|
|
95
|
+
title: one(found.objective, 200),
|
|
96
|
+
subtitle: found.status, subtitleTone: RUN_TONE[found.status][1],
|
|
97
|
+
fields: [
|
|
98
|
+
{ label: 'run', value: found.id },
|
|
99
|
+
{ label: 'started', value: found.startedAt ? ago(time(found.startedAt)) : 'not yet' },
|
|
100
|
+
...(found.endedAt ? [{ label: 'ended', value: ago(time(found.endedAt)) }] : []),
|
|
101
|
+
...(found.summary ? [{ label: 'summary', value: one(found.summary, 600) }] : []),
|
|
102
|
+
],
|
|
103
|
+
sections: [{ title: `Assignments (${work.length})`, lines: work.map(entry => assignmentLine(entry, 'run')) }],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
if (item.id.startsWith(EXPERT)) {
|
|
107
|
+
const person = expert(item.id.slice(EXPERT.length))!;
|
|
108
|
+
const work = team.assignments.filter(entry => entry.expertId === person.id).reverse();
|
|
109
|
+
const talk = [...state.talk].reverse().filter(entry => entry.from === person.role || entry.to === person.role);
|
|
110
|
+
return {
|
|
111
|
+
title: one(person.role, 80),
|
|
112
|
+
subtitle: person.status, subtitleTone: EXPERT_TONE[person.status][1],
|
|
113
|
+
fields: [
|
|
114
|
+
{ label: 'expert', value: person.id },
|
|
115
|
+
{ label: 'can', value: person.capabilities.join(', ') || '—' },
|
|
116
|
+
{ label: 'generation', value: String(person.generation) },
|
|
117
|
+
{ label: 'updated', value: ago(time(person.updatedAt)) },
|
|
118
|
+
...(person.memory ? [{ label: 'memory', value: one(person.memory, 400) }] : []),
|
|
119
|
+
],
|
|
120
|
+
sections: [
|
|
121
|
+
{ title: `Assignments (${work.length})`, lines: work.map(entry => assignmentLine(entry, 'expert')) },
|
|
122
|
+
{ title: 'Messages', lines: talk.map(entry => `${ago(time(entry.at))} ${peerLine(entry)}`) },
|
|
123
|
+
],
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
const blocked = team.experts.filter(entry => entry.status === 'blocked').length;
|
|
127
|
+
return {
|
|
128
|
+
title: 'team',
|
|
129
|
+
subtitle: blocked ? `${blocked} blocked expert${blocked === 1 ? '' : 's'} need manual process verification.` : 'Healthy.',
|
|
130
|
+
subtitleTone: blocked ? 'warning' : 'success',
|
|
131
|
+
fields: [
|
|
132
|
+
{ label: 'team', value: team.teamId },
|
|
133
|
+
{ label: 'owner', value: team.owner ? `recorded · epoch ${team.epoch} (not a liveness guarantee)` : 'none' },
|
|
134
|
+
{ label: 'this session', value: ops.isOwner() ? 'owner' : 'observer' },
|
|
135
|
+
{ label: 'parallel', value: `up to ${LIMITS.concurrent} experts` },
|
|
136
|
+
{ label: 'runs', value: String(team.runs.length) },
|
|
137
|
+
{ label: 'experts', value: String(team.experts.length) },
|
|
138
|
+
],
|
|
139
|
+
sections: [
|
|
140
|
+
...(team.orchestrator.summary ? [{ title: 'Orchestrator', lines: [one(team.orchestrator.summary, 800)] }] : []),
|
|
141
|
+
{ title: 'Recent assignments', lines: team.assignments.slice(-10).reverse().map(entry => assignmentLine(entry, 'run')) },
|
|
142
|
+
{ title: 'Experts talking directly', lines: [...state.talk].reverse().map(entry => `${ago(time(entry.at))} ${peerLine(entry)}`) },
|
|
143
|
+
],
|
|
144
|
+
};
|
|
145
|
+
},
|
|
146
|
+
actions,
|
|
147
|
+
empty: 'No Team for this project yet. Press n, or type /team <objective>.',
|
|
148
|
+
subscribe: listener => {
|
|
149
|
+
listeners.add(listener);
|
|
150
|
+
const timer = setInterval(() => { void reload().catch(() => undefined); }, 2000);
|
|
151
|
+
timer.unref?.();
|
|
152
|
+
return () => { listeners.delete(listener); clearInterval(timer); };
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
}
|