@imenam/mcp-organizer 1.0.1 → 1.0.2

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.
@@ -234,6 +234,7 @@ export declare const RequestListResponseSchema: z.ZodObject<{
234
234
  name: z.ZodString;
235
235
  ownerName: z.ZodString;
236
236
  lastUsedAt: z.ZodNullable<z.ZodISODateTime>;
237
+ listening: z.ZodBoolean;
237
238
  }, z.core.$strip>>;
238
239
  }, z.core.$strip>;
239
240
  export type RequestListResponse = z.infer<typeof RequestListResponseSchema>;
@@ -75,8 +75,11 @@ export const RequestListResponseSchema = z.object({
75
75
  /** Demandes de l'espace, de la plus récente à la plus ancienne. */
76
76
  requests: z.array(RequestViewSchema),
77
77
  pendingCount: z.number().int().min(0),
78
- /** Jetons actifs qui couvrent l'espace, du plus récemment utilisé au moins récemment. */
79
- agents: z.array(AgentRefSchema.extend({ lastUsedAt: TimestampSchema.nullable() })),
78
+ /**
79
+ * Jetons actifs qui couvrent l'espace, du plus récemment utilisé au moins
80
+ * récemment ; `listening` : un veilleur de notifications de ce jeton est armé.
81
+ */
82
+ agents: z.array(AgentRefSchema.extend({ lastUsedAt: TimestampSchema.nullable(), listening: z.boolean() })),
80
83
  });
81
84
  /** Version d'une tâche dans son historique. */
82
85
  export const TaskVersionViewSchema = z.object({
@@ -25,6 +25,20 @@ export declare const ListRequestsQuerySchema: z.ZodObject<{
25
25
  }>>;
26
26
  limit: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
27
27
  }, z.core.$strict>;
28
+ /** Durée maximale d'une attente longue de `GET requests/wait`, en secondes. */
29
+ export declare const MAX_WAIT_SECONDS = 50;
30
+ /**
31
+ * `GET requests/wait` : durée de l'attente ; `since`, curseur rendu par
32
+ * l'attente précédente (les demandes passées en attente depuis sont rendues
33
+ * aussitôt) ; `watcher=true` pour un veilleur de notifications.
34
+ */
35
+ export declare const WaitRequestsQuerySchema: z.ZodObject<{
36
+ timeout_seconds: z.ZodDefault<z.ZodCoercedNumber<unknown>>;
37
+ since: z.ZodOptional<z.ZodISODateTime>;
38
+ watcher: z.ZodOptional<z.ZodEnum<{
39
+ true: "true";
40
+ }>>;
41
+ }, z.core.$strict>;
28
42
  /** `POST requests/:id/claim` : sans corps. */
29
43
  export declare const ClaimRequestBodySchema: z.ZodObject<{}, z.core.$strict>;
30
44
  export declare const CompleteRequestBodySchema: z.ZodObject<{
@@ -722,3 +736,83 @@ export declare const PromoteIdeaDataSchema: z.ZodObject<{
722
736
  tags: z.ZodArray<z.ZodString>;
723
737
  }, z.core.$strip>;
724
738
  }, z.core.$strip>;
739
+ /** Présence d'un veilleur de notifications pour le jeton. */
740
+ export declare const AgentWatcherSchema: z.ZodObject<{
741
+ armed: z.ZodBoolean;
742
+ since: z.ZodNullable<z.ZodISODateTime>;
743
+ lastSeen: z.ZodNullable<z.ZodISODateTime>;
744
+ }, z.core.$strip>;
745
+ /** `GET notifications` : jeton et espace de l'agent, demandes en attente et veilleur du jeton. */
746
+ export declare const NotificationsDataSchema: z.ZodObject<{
747
+ token: z.ZodObject<{
748
+ id: z.ZodString;
749
+ name: z.ZodString;
750
+ }, z.core.$strip>;
751
+ space: z.ZodObject<{
752
+ id: z.ZodString;
753
+ name: z.ZodString;
754
+ kind: z.ZodEnum<{
755
+ personal: "personal";
756
+ shared: "shared";
757
+ }>;
758
+ keyPrefix: z.ZodString;
759
+ }, z.core.$strip>;
760
+ pendingCount: z.ZodNumber;
761
+ watcher: z.ZodObject<{
762
+ armed: z.ZodBoolean;
763
+ since: z.ZodNullable<z.ZodISODateTime>;
764
+ lastSeen: z.ZodNullable<z.ZodISODateTime>;
765
+ }, z.core.$strip>;
766
+ }, z.core.$strip>;
767
+ /**
768
+ * `GET requests/wait` : demandes passées en attente (vide à l'expiration) et
769
+ * curseur à repasser en `since` à l'attente suivante.
770
+ */
771
+ export declare const WaitRequestsDataSchema: z.ZodObject<{
772
+ space: z.ZodObject<{
773
+ id: z.ZodString;
774
+ name: z.ZodString;
775
+ kind: z.ZodEnum<{
776
+ personal: "personal";
777
+ shared: "shared";
778
+ }>;
779
+ keyPrefix: z.ZodString;
780
+ }, z.core.$strip>;
781
+ requests: z.ZodArray<z.ZodObject<{
782
+ id: z.ZodString;
783
+ kind: z.ZodEnum<{
784
+ idea: "idea";
785
+ idea_promotion: "idea_promotion";
786
+ message: "message";
787
+ remark: "remark";
788
+ test_review: "test_review";
789
+ }>;
790
+ status: z.ZodEnum<{
791
+ cancelled: "cancelled";
792
+ done: "done";
793
+ failed: "failed";
794
+ pending: "pending";
795
+ processing: "processing";
796
+ }>;
797
+ text: z.ZodString;
798
+ author: z.ZodObject<{
799
+ id: z.ZodString;
800
+ name: z.ZodString;
801
+ email: z.ZodString;
802
+ }, z.core.$strip>;
803
+ contextTask: z.ZodNullable<z.ZodObject<{
804
+ id: z.ZodString;
805
+ key: z.ZodString;
806
+ title: z.ZodString;
807
+ }, z.core.$strip>>;
808
+ contextIdeaId: z.ZodNullable<z.ZodString>;
809
+ createdAt: z.ZodISODateTime;
810
+ claimedAt: z.ZodNullable<z.ZodISODateTime>;
811
+ claimedByThisToken: z.ZodBoolean;
812
+ reply: z.ZodNullable<z.ZodString>;
813
+ failureReason: z.ZodNullable<z.ZodString>;
814
+ }, z.core.$strip>>;
815
+ cursor: z.ZodISODateTime;
816
+ timedOut: z.ZodBoolean;
817
+ }, z.core.$strip>;
818
+ export type WaitRequestsData = z.infer<typeof WaitRequestsDataSchema>;
@@ -26,6 +26,20 @@ export const ListRequestsQuerySchema = z
26
26
  limit: z.coerce.number().int().min(1).max(MAX_REQUEST_LIST_LIMIT).default(20),
27
27
  })
28
28
  .strict();
29
+ /** Durée maximale d'une attente longue de `GET requests/wait`, en secondes. */
30
+ export const MAX_WAIT_SECONDS = 50;
31
+ /**
32
+ * `GET requests/wait` : durée de l'attente ; `since`, curseur rendu par
33
+ * l'attente précédente (les demandes passées en attente depuis sont rendues
34
+ * aussitôt) ; `watcher=true` pour un veilleur de notifications.
35
+ */
36
+ export const WaitRequestsQuerySchema = z
37
+ .object({
38
+ timeout_seconds: z.coerce.number().int().min(1).max(MAX_WAIT_SECONDS).default(30),
39
+ since: TimestampSchema.optional(),
40
+ watcher: z.enum(["true"]).optional(),
41
+ })
42
+ .strict();
29
43
  /** `POST requests/:id/claim` : sans corps. */
30
44
  export const ClaimRequestBodySchema = z.object({}).strict();
31
45
  export const CompleteRequestBodySchema = z.object({ reply: z.string().max(MAX_REPLY_LENGTH) }).strict();
@@ -163,3 +177,22 @@ export const ChecklistDataSchema = z.object({
163
177
  export const CommentDataSchema = z.object({ task: AgentTaskSummarySchema, commentId: IdSchema });
164
178
  export const IdeaDataSchema = z.object({ idea: AgentIdeaSchema });
165
179
  export const PromoteIdeaDataSchema = z.object({ idea: AgentIdeaSchema, task: AgentTaskSummarySchema });
180
+ /** Présence d'un veilleur de notifications pour le jeton. */
181
+ export const AgentWatcherSchema = z.object({ armed: z.boolean(), since: TimestampSchema.nullable(), lastSeen: TimestampSchema.nullable() });
182
+ /** `GET notifications` : jeton et espace de l'agent, demandes en attente et veilleur du jeton. */
183
+ export const NotificationsDataSchema = z.object({
184
+ token: z.object({ id: IdSchema, name: z.string() }),
185
+ space: AgentSpaceSchema,
186
+ pendingCount: z.number().int().min(0),
187
+ watcher: AgentWatcherSchema,
188
+ });
189
+ /**
190
+ * `GET requests/wait` : demandes passées en attente (vide à l'expiration) et
191
+ * curseur à repasser en `since` à l'attente suivante.
192
+ */
193
+ export const WaitRequestsDataSchema = z.object({
194
+ space: AgentSpaceSchema,
195
+ requests: z.array(AgentRequestSummarySchema),
196
+ cursor: TimestampSchema,
197
+ timedOut: z.boolean(),
198
+ });
@@ -0,0 +1,16 @@
1
+ import { type AgentApiContext, type AgentApiRequest, type AgentApiResponse, type AgentRoute } from "./types.js";
2
+ /**
3
+ * Notifications de l'agent : état (`GET notifications`) et attente longue
4
+ * des demandes qui passent en attente (`GET requests/wait`), appelée en
5
+ * boucle par le veilleur que lance `enable_notifications`.
6
+ */
7
+ export declare const notificationRoutes: AgentRoute[];
8
+ /**
9
+ * `GET requests/wait` : rend aussitôt les demandes passées en attente depuis
10
+ * `since`, sinon attend qu'une demande de l'espace du jeton passe en attente
11
+ * (création ou libération), au plus `timeout_seconds`. Le curseur rendu est
12
+ * l'horodatage de la dernière demande rendue, ou le point de départ de
13
+ * l'attente à l'expiration. Une attente `watcher=true` compte dans la
14
+ * présence du veilleur du jeton.
15
+ */
16
+ export declare function waitForPendingRequests(ctx: AgentApiContext, request: AgentApiRequest, signal: AbortSignal): Promise<AgentApiResponse>;
@@ -0,0 +1,91 @@
1
+ import { MAX_REQUEST_LIST_LIMIT, NoQuerySchema, NotificationsDataSchema, WaitRequestsDataSchema, WaitRequestsQuerySchema, } from "../../shared/schemas/agent-tools.schema.js";
2
+ import { resolveAgentToken } from "../core/agent-tokens.js";
3
+ import { requestData, requestLine, spaceData } from "../core/agent.js";
4
+ import { parseInput, respond } from "./types.js";
5
+ /**
6
+ * Notifications de l'agent : état (`GET notifications`) et attente longue
7
+ * des demandes qui passent en attente (`GET requests/wait`), appelée en
8
+ * boucle par le veilleur que lance `enable_notifications`.
9
+ */
10
+ export const notificationRoutes = [
11
+ {
12
+ method: "GET",
13
+ path: "/notifications",
14
+ handle: ({ store }, { agent, query }) => {
15
+ parseInput(NoQuerySchema, query);
16
+ const watcher = store.watchers.status(agent.token.id);
17
+ const pendingCount = store.countPendingRequestsOfSpace(agent.space.id);
18
+ const state = watcher.armed ? `a watcher is armed for this token since ${watcher.since}` : "no watcher is armed for this token";
19
+ return respond(NotificationsDataSchema, `Space "${agent.space.name}": ${pendingCount} pending request(s); ${state}.`, {
20
+ token: { id: agent.token.id, name: agent.token.name },
21
+ space: spaceData(agent.space),
22
+ pendingCount,
23
+ watcher,
24
+ });
25
+ },
26
+ },
27
+ ];
28
+ /** Demandes en attente de l'espace du jeton modifiées strictement après `since`. */
29
+ function pendingSince(ctx, agent, since) {
30
+ return ctx.store.listAgentRequestsTargeting(agent.space.id, "pending", MAX_REQUEST_LIST_LIMIT).filter((request) => request.updatedAt > since);
31
+ }
32
+ function waitResponse(ctx, agent, requests, cursor, timeoutSeconds) {
33
+ const data = {
34
+ space: spaceData(agent.space),
35
+ requests: requests.map((request) => requestData(ctx.store, agent, request)),
36
+ cursor,
37
+ timedOut: requests.length === 0,
38
+ };
39
+ if (requests.length === 0)
40
+ return respond(WaitRequestsDataSchema, `No new pending request in "${agent.space.name}" after ${timeoutSeconds}s.`, data);
41
+ const lines = requests.map((request) => requestLine(ctx.store, agent, request)).join("\n");
42
+ return respond(WaitRequestsDataSchema, `${requests.length} new pending request(s) in "${agent.space.name}":\n${lines}\n\nNext: call claim_request with the id of the oldest request.`, data);
43
+ }
44
+ /**
45
+ * `GET requests/wait` : rend aussitôt les demandes passées en attente depuis
46
+ * `since`, sinon attend qu'une demande de l'espace du jeton passe en attente
47
+ * (création ou libération), au plus `timeout_seconds`. Le curseur rendu est
48
+ * l'horodatage de la dernière demande rendue, ou le point de départ de
49
+ * l'attente à l'expiration. Une attente `watcher=true` compte dans la
50
+ * présence du veilleur du jeton.
51
+ */
52
+ export function waitForPendingRequests(ctx, request, signal) {
53
+ const agent = resolveAgentToken(ctx.store, request.bearer, ctx.now?.() ?? new Date());
54
+ const input = parseInput(WaitRequestsQuerySchema, request.query);
55
+ const startedAt = new Date().toISOString();
56
+ const since = input.since ?? null;
57
+ if (since !== null) {
58
+ const ready = pendingSince(ctx, agent, since);
59
+ if (ready.length > 0)
60
+ return Promise.resolve(waitResponse(ctx, agent, ready, ready.map((r) => r.updatedAt).sort().at(-1), input.timeout_seconds));
61
+ }
62
+ return new Promise((resolve) => {
63
+ const releaseWatcher = input.watcher ? ctx.store.watchers.begin(agent.token.id) : () => { };
64
+ const received = [];
65
+ let flush = null;
66
+ const stop = () => {
67
+ clearTimeout(timer);
68
+ if (flush)
69
+ clearImmediate(flush);
70
+ unsubscribe();
71
+ signal.removeEventListener("abort", onAbort);
72
+ releaseWatcher();
73
+ };
74
+ const finish = (requests, cursor) => {
75
+ stop();
76
+ resolve(waitResponse(ctx, agent, requests, cursor, input.timeout_seconds));
77
+ };
78
+ // Les demandes d'une même mutation arrivent ensemble : elles partent dans la même réponse.
79
+ const unsubscribe = ctx.store.onPendingRequest((pending) => {
80
+ if (pending.targetSpaceId !== agent.space.id || pending.status !== "pending")
81
+ return;
82
+ received.push(pending);
83
+ flush ??= setImmediate(() => finish(received, received.map((r) => r.updatedAt).sort().at(-1)));
84
+ });
85
+ const timer = setTimeout(() => finish([], since ?? startedAt), input.timeout_seconds * 1000);
86
+ const onAbort = () => stop();
87
+ signal.addEventListener("abort", onAbort);
88
+ if (signal.aborted)
89
+ stop();
90
+ });
91
+ }
@@ -2,6 +2,7 @@ import { resolveAgentToken } from "../core/agent-tokens.js";
2
2
  import { NotFoundError, OrganizerError } from "../core/errors.js";
3
3
  import { checklistRoutes } from "./checklist.js";
4
4
  import { ideaRoutes } from "./ideas.js";
5
+ import { notificationRoutes } from "./notifications.js";
5
6
  import { requestRoutes } from "./requests.js";
6
7
  import { taskRoutes } from "./tasks.js";
7
8
  function compile(route) {
@@ -12,7 +13,7 @@ function compile(route) {
12
13
  });
13
14
  return { route, pattern: new RegExp(`^${source}$`), keys };
14
15
  }
15
- const ROUTES = [...requestRoutes, ...taskRoutes, ...checklistRoutes, ...ideaRoutes].map(compile);
16
+ const ROUTES = [...requestRoutes, ...taskRoutes, ...checklistRoutes, ...ideaRoutes, ...notificationRoutes].map(compile);
16
17
  /** Segment de chemin décodé ; un encodage `%` invalide est une erreur 400. */
17
18
  function decodeSegment(raw) {
18
19
  try {
@@ -0,0 +1,4 @@
1
+ import { type AgentRequestSummary } from "../../shared/schemas/agent-tools.schema.js";
2
+ /** Ligne d'une demande : une seule ligne, lisible telle quelle dans la notification. */
3
+ export declare function formatRequestEvent(request: AgentRequestSummary): string;
4
+ export declare function runWatchMode(tokenId: string | undefined): Promise<number>;
@@ -0,0 +1,62 @@
1
+ import { MAX_WAIT_SECONDS } from "../../shared/schemas/agent-tools.schema.js";
2
+ import { ConfigError } from "../core/errors.js";
3
+ import { OrganizerSiteClient, SiteError } from "../mcp/client.js";
4
+ import { readHandoff } from "../mcp/watch-handoff.js";
5
+ /**
6
+ * Mode veilleur (`--watch <identifiant du jeton>`).
7
+ *
8
+ * Une ligne sur stdout = une demande passée en attente. Ce flux est consommé
9
+ * par l'outil `Monitor` de l'hôte, qui réveille la session de l'agent à chaque
10
+ * ligne, y compris quand elle est inactive. Le journal part sur stderr.
11
+ *
12
+ * Une panne réseau ou du site est réessayée avec un délai croissant. Un refus
13
+ * du site (jeton révoqué, expiré, route inconnue) arrête le veilleur après une
14
+ * dernière ligne qui en donne la raison.
15
+ */
16
+ const BACKOFF_MIN_MS = 1_000;
17
+ const BACKOFF_MAX_MS = 30_000;
18
+ /** Longueur maximale de l'extrait du texte de la demande. */
19
+ const EXCERPT_LENGTH = 120;
20
+ /** Ligne d'une demande : une seule ligne, lisible telle quelle dans la notification. */
21
+ export function formatRequestEvent(request) {
22
+ const text = request.text.replace(/\s+/g, " ").trim();
23
+ const excerpt = text.length > EXCERPT_LENGTH ? `${text.slice(0, EXCERPT_LENGTH - 1)}…` : text;
24
+ const context = request.contextTask ? ` · ${request.contextTask.key}` : "";
25
+ return `[organizer] ${request.kind} · ${request.author.name} — "${excerpt}"${context} (${request.id})`;
26
+ }
27
+ /** Refus définitif du site : un nouvel essai donnerait la même réponse. */
28
+ function isRefusal(error) {
29
+ return error instanceof SiteError && error.status !== undefined && error.status >= 400 && error.status < 500 && error.status !== 408 && error.status !== 429;
30
+ }
31
+ export async function runWatchMode(tokenId) {
32
+ if (!tokenId || tokenId.startsWith("--")) {
33
+ throw new ConfigError("--watch needs the token id given by the enable_notifications tool: `mcp-organizer --watch <token id>`.");
34
+ }
35
+ const handoff = readHandoff(tokenId);
36
+ if (!handoff) {
37
+ throw new ConfigError(`No notification handoff for token ${tokenId}: call the enable_notifications tool of the organizer MCP server, then arm the watcher with the command it returns.`);
38
+ }
39
+ const client = new OrganizerSiteClient({ url: handoff.url, token: handoff.token });
40
+ process.stderr.write(`watching token ${tokenId} on ${handoff.url}\n`);
41
+ let since;
42
+ let backoff = BACKOFF_MIN_MS;
43
+ for (;;) {
44
+ try {
45
+ const result = await client.waitForRequests({ timeout_seconds: MAX_WAIT_SECONDS, since, watcher: true });
46
+ backoff = BACKOFF_MIN_MS;
47
+ since = result.cursor;
48
+ for (const request of result.requests)
49
+ process.stdout.write(`${formatRequestEvent(request)}\n`);
50
+ }
51
+ catch (error) {
52
+ const detail = error instanceof Error ? error.message : String(error);
53
+ if (isRefusal(error)) {
54
+ process.stdout.write(`[organizer] notifications stopped: ${detail.replace(/\s+/g, " ")}\n`);
55
+ return 1;
56
+ }
57
+ process.stderr.write(`wait failed, retrying in ${Math.round(backoff / 1000)}s: ${detail}\n`);
58
+ await new Promise((resolve) => setTimeout(resolve, backoff));
59
+ backoff = Math.min(backoff * 2, BACKOFF_MAX_MS);
60
+ }
61
+ }
62
+ }
@@ -40,7 +40,8 @@ export declare function releaseRequest(store: Store, userId: string, requestId:
40
40
  export declare function getRequest(store: Store, userId: string, requestId: string): RequestView;
41
41
  /**
42
42
  * Demandes d'un espace, avec le nombre de demandes en attente et les jetons
43
- * actifs qui couvrent l'espace, chacun avec son dernier usage.
43
+ * actifs qui couvrent l'espace, chacun avec son dernier usage et la présence
44
+ * de son veilleur de notifications.
44
45
  */
45
46
  export declare function listRequests(store: Store, userId: string, spaceId: string, now?: Date): RequestListResponse;
46
47
  /** Annule une action de l'agent. */
@@ -190,7 +190,8 @@ export function getRequest(store, userId, requestId) {
190
190
  }
191
191
  /**
192
192
  * Demandes d'un espace, avec le nombre de demandes en attente et les jetons
193
- * actifs qui couvrent l'espace, chacun avec son dernier usage.
193
+ * actifs qui couvrent l'espace, chacun avec son dernier usage et la présence
194
+ * de son veilleur de notifications.
194
195
  */
195
196
  export function listRequests(store, userId, spaceId, now = new Date()) {
196
197
  requireMember(store, spaceId, userId);
@@ -198,7 +199,7 @@ export function listRequests(store, userId, spaceId, now = new Date()) {
198
199
  const agents = store
199
200
  .listAgentTokensOfSpace(spaceId)
200
201
  .filter((token) => tokenState(store, token, now) === "active")
201
- .map((token) => ({ ...agentRef(store, token), lastUsedAt: token.lastUsedAt }));
202
+ .map((token) => ({ ...agentRef(store, token), lastUsedAt: token.lastUsedAt, listening: store.watchers.status(token.id).armed }));
202
203
  return { requests, pendingCount: store.countPendingRequestsOfSpace(spaceId), agents };
203
204
  }
204
205
  // --- Annulation -----------------------------------------------------------------
@@ -1,4 +1,5 @@
1
1
  import type Database from "better-sqlite3";
2
+ import { WatcherRegistry } from "./watchers.js";
2
3
  import { type EntityType, type MemberRole, type RequestKind, type RequestStatus, type SpaceKind, type TaskPriority, type TaskStatus, type TaskType, type TestResultValue, type AccessLinkPurpose, type AgentOperation, type UserStatus, type VersionedEntityType } from "../../shared/schemas/enums.schema.js";
3
4
  import { type AccessLink, type AgentAction, type AgentRequest, type AgentToken, type Attachment, type Change, type ChecklistItem, type Comment, type EntityVersion, type Idea, type Invitation, type Membership, type Notification, type Session, type Space, type Tag, type Task, type TaskSnapshot, type TestCheckpoint, type TestResult, type TestReview, type User, type UserSettings } from "../../shared/schemas/entities.schema.js";
4
5
  /**
@@ -24,6 +25,9 @@ export declare class Store {
24
25
  readonly db: Database.Database;
25
26
  private activeMutation;
26
27
  private emailListener;
28
+ private readonly pendingRequestListeners;
29
+ /** Présence des veilleurs de notifications, par jeton d'agent. */
30
+ readonly watchers: WatcherRegistry;
27
31
  constructor(db: Database.Database);
28
32
  /** Ouvre (et migre) la base du répertoire de données. */
29
33
  static open(dataDir: string): Store;
@@ -180,6 +184,12 @@ export declare class Store {
180
184
  * créées.
181
185
  */
182
186
  setEmailListener(listener: ((notificationIds: string[]) => void) | null): void;
187
+ /**
188
+ * Abonne `listener` aux demandes qui passent en attente (créées, ou
189
+ * libérées par un membre) : il les reçoit après la validation de la
190
+ * mutation. La fonction rendue désabonne.
191
+ */
192
+ onPendingRequest(listener: (request: AgentRequest) => void): () => void;
183
193
  /**
184
194
  * Point unique d'écriture. `fn` reçoit une `Mutation` et s'exécute dans une
185
195
  * transaction exclusive ; à sa sortie, les invariants sont vérifiés sur l'état
@@ -287,6 +297,8 @@ export declare class Mutation {
287
297
  private readonly touched;
288
298
  /** Notifications créées par la mutation dont le destinataire veut un e-mail. */
289
299
  readonly emailNotificationIds: string[];
300
+ /** Demandes passées en attente par la mutation : créées, ou libérées. */
301
+ readonly pendingRequestIds: string[];
290
302
  /** Demande pour laquelle agit l'agent, lue au début de la mutation. */
291
303
  private readonly request;
292
304
  /** Jeton avec lequel agit l'agent. */
@@ -2,6 +2,7 @@ import { openDatabase } from "./db.js";
2
2
  import { ForbiddenError, InvariantError, NotFoundError, OrganizerError } from "./errors.js";
3
3
  import { positionBetween } from "../../shared/logic/positions.js";
4
4
  import { newId } from "./ids.js";
5
+ import { WatcherRegistry } from "./watchers.js";
5
6
  import { MAX_TASK_DEPTH, } from "../../shared/schemas/enums.schema.js";
6
7
  import { AccessLinkSchema, AgentActionSchema, AgentTokenSchema, AgentRequestSchema, AttachmentSchema, ChangeSchema, ChecklistItemSchema, CommentSchema, EntityVersionSchema, IdeaSchema, IdeaSnapshotSchema, InvitationSchema, MembershipSchema, NotificationSchema, SessionSchema, SpaceSchema, TagSchema, TaskSchema, TaskSnapshotSchema, TestCheckpointSchema, TestResultSchema, TestReviewSchema, UserSchema, UserSettingsSchema, } from "../../shared/schemas/entities.schema.js";
7
8
  /** Valide `value` avec `schema` ; une donnée invalide est une erreur métier qui liste les champs fautifs. */
@@ -276,6 +277,9 @@ export class Store {
276
277
  db;
277
278
  activeMutation = null;
278
279
  emailListener = null;
280
+ pendingRequestListeners = new Set();
281
+ /** Présence des veilleurs de notifications, par jeton d'agent. */
282
+ watchers = new WatcherRegistry();
279
283
  constructor(db) {
280
284
  this.db = db;
281
285
  }
@@ -767,6 +771,15 @@ export class Store {
767
771
  setEmailListener(listener) {
768
772
  this.emailListener = listener;
769
773
  }
774
+ /**
775
+ * Abonne `listener` aux demandes qui passent en attente (créées, ou
776
+ * libérées par un membre) : il les reçoit après la validation de la
777
+ * mutation. La fonction rendue désabonne.
778
+ */
779
+ onPendingRequest(listener) {
780
+ this.pendingRequestListeners.add(listener);
781
+ return () => this.pendingRequestListeners.delete(listener);
782
+ }
770
783
  /**
771
784
  * Point unique d'écriture. `fn` reçoit une `Mutation` et s'exécute dans une
772
785
  * transaction exclusive ; à sa sortie, les invariants sont vérifiés sur l'état
@@ -777,6 +790,7 @@ export class Store {
777
790
  if (this.activeMutation)
778
791
  throw new Error("Store.mutate is not reentrant: use the Mutation passed to the running mutation.");
779
792
  let emails = [];
793
+ let pending = [];
780
794
  const run = this.db.transaction(() => {
781
795
  const tx = new Mutation(this, actor);
782
796
  this.activeMutation = tx;
@@ -784,6 +798,7 @@ export class Store {
784
798
  const result = fn(tx);
785
799
  tx.finish();
786
800
  emails = tx.emailNotificationIds;
801
+ pending = tx.pendingRequestIds;
787
802
  return result;
788
803
  }
789
804
  finally {
@@ -793,6 +808,13 @@ export class Store {
793
808
  const result = run.immediate();
794
809
  if (emails.length > 0 && this.emailListener)
795
810
  this.emailListener(emails);
811
+ if (pending.length > 0 && this.pendingRequestListeners.size > 0) {
812
+ for (const id of pending) {
813
+ const request = this.getAgentRequest(id);
814
+ for (const listener of [...this.pendingRequestListeners])
815
+ listener(request);
816
+ }
817
+ }
796
818
  return result;
797
819
  }
798
820
  }
@@ -809,6 +831,8 @@ export class Mutation {
809
831
  touched = new Map();
810
832
  /** Notifications créées par la mutation dont le destinataire veut un e-mail. */
811
833
  emailNotificationIds = [];
834
+ /** Demandes passées en attente par la mutation : créées, ou libérées. */
835
+ pendingRequestIds = [];
812
836
  /** Demande pour laquelle agit l'agent, lue au début de la mutation. */
813
837
  request;
814
838
  /** Jeton avec lequel agit l'agent. */
@@ -1690,6 +1714,7 @@ export class Mutation {
1690
1714
  VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`)
1691
1715
  .run(request.id, request.authorId, request.targetSpaceId, request.kind, request.contextTaskId, request.contextIdeaId, request.text, request.status, request.createdAt, request.updatedAt);
1692
1716
  this.createdRequestIds.add(request.id);
1717
+ this.pendingRequestIds.push(request.id);
1693
1718
  this.touchRequest(request);
1694
1719
  return request;
1695
1720
  }
@@ -1735,6 +1760,7 @@ export class Mutation {
1735
1760
  throw new InvariantError(`Request ${id} is ${current.status}: only a processing request can be released.`);
1736
1761
  this.db.prepare("UPDATE agent_requests SET status = 'pending', claimed_at = NULL, claimed_by_token_id = NULL, updated_at = ? WHERE id = ?").run(this.now, id);
1737
1762
  const released = this.store.getAgentRequest(id);
1763
+ this.pendingRequestIds.push(id);
1738
1764
  this.touchRequest(released);
1739
1765
  return released;
1740
1766
  }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Présence des veilleurs de notifications, par jeton d'agent.
3
+ *
4
+ * Un veilleur enchaîne des attentes longues sur `GET /api/agent/requests/wait`.
5
+ * Le registre rend sa présence observable : `enable_notifications` répond
6
+ * « déjà armé », la page Agent de la GUI affiche « à l'écoute ».
7
+ *
8
+ * État de connexion, donc volatile : il vit en mémoire du processus du site et
9
+ * disparaît avec lui.
10
+ */
11
+ /**
12
+ * Délai de grâce entre deux attentes d'un même veilleur : le jeton reste
13
+ * « à l'écoute » pendant le court intervalle où aucune attente n'est ouverte.
14
+ */
15
+ export declare const WATCHER_GRACE_MS: number;
16
+ export interface WatcherStatus {
17
+ armed: boolean;
18
+ /** Début de la série d'attentes en cours, null sans veilleur. */
19
+ since: string | null;
20
+ /** Dernière activité connue d'un veilleur de ce jeton, null si aucune. */
21
+ lastSeen: string | null;
22
+ }
23
+ export declare class WatcherRegistry {
24
+ private readonly now;
25
+ private readonly presence;
26
+ constructor(now?: () => number);
27
+ /** Déclare une attente de veilleur du jeton `tokenId`. La fonction rendue la clôt ; elle doit toujours être appelée. */
28
+ begin(tokenId: string): () => void;
29
+ status(tokenId: string): WatcherStatus;
30
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * Présence des veilleurs de notifications, par jeton d'agent.
3
+ *
4
+ * Un veilleur enchaîne des attentes longues sur `GET /api/agent/requests/wait`.
5
+ * Le registre rend sa présence observable : `enable_notifications` répond
6
+ * « déjà armé », la page Agent de la GUI affiche « à l'écoute ».
7
+ *
8
+ * État de connexion, donc volatile : il vit en mémoire du processus du site et
9
+ * disparaît avec lui.
10
+ */
11
+ /**
12
+ * Délai de grâce entre deux attentes d'un même veilleur : le jeton reste
13
+ * « à l'écoute » pendant le court intervalle où aucune attente n'est ouverte.
14
+ */
15
+ export const WATCHER_GRACE_MS = 2 * 60 * 1000;
16
+ export class WatcherRegistry {
17
+ now;
18
+ presence = new Map();
19
+ constructor(now = Date.now) {
20
+ this.now = now;
21
+ }
22
+ /** Déclare une attente de veilleur du jeton `tokenId`. La fonction rendue la clôt ; elle doit toujours être appelée. */
23
+ begin(tokenId) {
24
+ const now = this.now();
25
+ const current = this.presence.get(tokenId);
26
+ const continuing = current !== undefined && (current.inflight > 0 || now - current.lastSeen <= WATCHER_GRACE_MS);
27
+ this.presence.set(tokenId, continuing ? { ...current, inflight: current.inflight + 1, lastSeen: now } : { inflight: 1, since: now, lastSeen: now });
28
+ let released = false;
29
+ return () => {
30
+ if (released)
31
+ return;
32
+ released = true;
33
+ const active = this.presence.get(tokenId);
34
+ if (!active)
35
+ return;
36
+ this.presence.set(tokenId, { ...active, inflight: Math.max(0, active.inflight - 1), lastSeen: this.now() });
37
+ };
38
+ }
39
+ status(tokenId) {
40
+ const entry = this.presence.get(tokenId);
41
+ if (!entry)
42
+ return { armed: false, since: null, lastSeen: null };
43
+ const armed = entry.inflight > 0 || this.now() - entry.lastSeen <= WATCHER_GRACE_MS;
44
+ if (!armed) {
45
+ this.presence.delete(tokenId);
46
+ return { armed: false, since: null, lastSeen: new Date(entry.lastSeen).toISOString() };
47
+ }
48
+ return { armed: true, since: new Date(entry.since).toISOString(), lastSeen: new Date(entry.lastSeen).toISOString() };
49
+ }
50
+ }
package/dist/src/index.js CHANGED
@@ -19,6 +19,10 @@ Usage :
19
19
  (dossier <fichier>.attachments).
20
20
  mcp-organizer --claude-setup-mcp [--force] --url=<URL du site> --token=<jeton>
21
21
  Écrit l'entrée du mode MCP dans ./.mcp.json.
22
+ mcp-organizer --watch <id du jeton>
23
+ Veilleur de notifications : une ligne par demande passée en attente.
24
+ Lancé par l'outil Monitor de l'agent, avec la commande que rend
25
+ l'outil enable_notifications.
22
26
  mcp-organizer --version Affiche la version.
23
27
  mcp-organizer --help Affiche cette aide.
24
28
 
@@ -50,6 +54,12 @@ async function main() {
50
54
  const { runSetupMcp } = await import("./cli/setup-mcp.js");
51
55
  process.exit(runSetupMcp(argv, process.cwd()));
52
56
  }
57
+ // Le veilleur lit l'URL du site et le jeton dans le relais déposé par enable_notifications.
58
+ const watchIndex = argv.indexOf("--watch");
59
+ if (watchIndex !== -1) {
60
+ const { runWatchMode } = await import("./cli/watch.js");
61
+ process.exit(await runWatchMode(argv[watchIndex + 1]));
62
+ }
53
63
  const rootDir = findProjectRoot(path.dirname(fileURLToPath(import.meta.url)));
54
64
  const siteCommand = SITE_COMMANDS.some((flag) => argv.includes(flag));
55
65
  // Les commandes du site ouvrent la base : better-sqlite3 doit avoir son module natif.