@parall/agent-core 1.31.0 → 1.32.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (72) hide show
  1. package/dist/bridge-workspace.d.ts +1 -1
  2. package/dist/bridge-workspace.d.ts.map +1 -1
  3. package/dist/bridge-workspace.js +13 -13
  4. package/dist/dispatch-adapter.d.ts +15 -8
  5. package/dist/dispatch-adapter.d.ts.map +1 -1
  6. package/dist/event-format.d.ts +1 -1
  7. package/dist/event-format.d.ts.map +1 -1
  8. package/dist/event-format.js +68 -25
  9. package/dist/gateway-base.d.ts +14 -13
  10. package/dist/gateway-base.d.ts.map +1 -1
  11. package/dist/gateway-base.js +650 -313
  12. package/dist/index.d.ts +15 -13
  13. package/dist/index.d.ts.map +1 -1
  14. package/dist/index.js +13 -12
  15. package/dist/internal/attachment-input.d.ts +3 -3
  16. package/dist/internal/attachment-input.d.ts.map +1 -1
  17. package/dist/internal/attachment-input.js +61 -58
  18. package/dist/logger.d.ts +1 -1
  19. package/dist/platform-config.d.ts +28 -2
  20. package/dist/platform-config.d.ts.map +1 -1
  21. package/dist/platform-config.js +42 -11
  22. package/dist/prompt-fragments.d.ts +2 -2
  23. package/dist/prompt-fragments.d.ts.map +1 -1
  24. package/dist/prompt-fragments.js +37 -14
  25. package/dist/provider-config.d.ts +9 -0
  26. package/dist/provider-config.d.ts.map +1 -1
  27. package/dist/provider-config.js +13 -2
  28. package/dist/routing.d.ts +5 -5
  29. package/dist/routing.js +6 -6
  30. package/dist/session-state.d.ts +16 -0
  31. package/dist/session-state.d.ts.map +1 -1
  32. package/dist/session-state.js +45 -0
  33. package/dist/skills/index.d.ts +5 -4
  34. package/dist/skills/index.d.ts.map +1 -1
  35. package/dist/skills/index.js +28 -21
  36. package/dist/skills/parall-clips.d.ts +2 -0
  37. package/dist/skills/parall-clips.d.ts.map +1 -0
  38. package/dist/skills/parall-clips.js +44 -0
  39. package/dist/skills/parall-platform.d.ts +1 -1
  40. package/dist/skills/parall-platform.d.ts.map +1 -1
  41. package/dist/skills/parall-platform.js +6 -2
  42. package/dist/skills/parall-tasks.d.ts +1 -1
  43. package/dist/skills/parall-tasks.d.ts.map +1 -1
  44. package/dist/skills/parall-tasks.js +1 -1
  45. package/dist/skills/parall-wiki.d.ts +1 -1
  46. package/dist/skills/parall-wiki.d.ts.map +1 -1
  47. package/dist/skills/parall-wiki.js +1 -1
  48. package/dist/telemetry.d.ts +27 -0
  49. package/dist/telemetry.d.ts.map +1 -0
  50. package/dist/telemetry.js +205 -0
  51. package/dist/types.d.ts +18 -2
  52. package/dist/types.d.ts.map +1 -1
  53. package/package.json +11 -2
  54. package/src/bridge-workspace.ts +13 -13
  55. package/src/dispatch-adapter.ts +31 -8
  56. package/src/event-format.ts +80 -30
  57. package/src/gateway-base.ts +988 -445
  58. package/src/index.ts +23 -13
  59. package/src/internal/attachment-input.ts +127 -100
  60. package/src/logger.ts +1 -1
  61. package/src/platform-config.ts +61 -16
  62. package/src/prompt-fragments.ts +37 -14
  63. package/src/provider-config.ts +14 -2
  64. package/src/routing.ts +11 -11
  65. package/src/session-state.ts +62 -0
  66. package/src/skills/index.ts +34 -23
  67. package/src/skills/parall-clips.ts +44 -0
  68. package/src/skills/parall-platform.ts +6 -2
  69. package/src/skills/parall-tasks.ts +1 -1
  70. package/src/skills/parall-wiki.ts +1 -1
  71. package/src/telemetry.ts +252 -0
  72. package/src/types.ts +18 -2
@@ -0,0 +1,205 @@
1
+ import { AsyncLocalStorage } from 'node:async_hooks';
2
+ import { trace, metrics, SpanStatusCode, } from '@opentelemetry/api';
3
+ import { SeverityNumber } from '@opentelemetry/api-logs';
4
+ let initialized = false;
5
+ let shutdownFn = null;
6
+ let tracer = null;
7
+ let dispatchCounter = null;
8
+ let dispatchDuration = null;
9
+ let missingReplyCounter = null;
10
+ let otelLogger = null;
11
+ function resolveTargetType(targetId) {
12
+ if (targetId.startsWith('cht_'))
13
+ return 'chat';
14
+ if (targetId.startsWith('tsk_'))
15
+ return 'task';
16
+ if (targetId.startsWith('sch_'))
17
+ return 'schedule';
18
+ return 'unknown';
19
+ }
20
+ /**
21
+ * Initialize agent telemetry. All agents export OTLP to the Parall
22
+ * telemetry-service (`PRLL_API_URL/otel`), authenticated with `PRLL_API_KEY`.
23
+ * The service canonicalizes identity from the token and proxies to SigNoz.
24
+ *
25
+ * Resource attributes include machine/agent/org identity from env.
26
+ * Returns a no-op handle when `PRLL_API_URL` is absent (local dev).
27
+ */
28
+ export async function initAgentTelemetry(serviceName, runtimeType) {
29
+ const noopHandle = { shutdown: async () => { } };
30
+ const apiUrl = process.env.PRLL_API_URL;
31
+ const apiKey = process.env.PRLL_API_KEY;
32
+ if (!apiUrl || !apiKey) {
33
+ return noopHandle;
34
+ }
35
+ try {
36
+ const otelEndpoint = apiUrl.replace(/\/$/, '') + '/otel';
37
+ const { OTLPTraceExporter } = await import('@opentelemetry/exporter-trace-otlp-proto');
38
+ const { OTLPMetricExporter } = await import('@opentelemetry/exporter-metrics-otlp-proto');
39
+ const { OTLPLogExporter } = await import('@opentelemetry/exporter-logs-otlp-proto');
40
+ const { NodeTracerProvider, BatchSpanProcessor } = await import('@opentelemetry/sdk-trace-node');
41
+ const { MeterProvider, PeriodicExportingMetricReader } = await import('@opentelemetry/sdk-metrics');
42
+ const { LoggerProvider, BatchLogRecordProcessor } = await import('@opentelemetry/sdk-logs');
43
+ const { Resource } = await import('@opentelemetry/resources');
44
+ const resource = new Resource({
45
+ 'service.name': serviceName,
46
+ 'service.version': process.env.npm_package_version || 'unknown',
47
+ 'deployment.environment.name': process.env.PRLL_SERVER_ENV || process.env.NODE_ENV || 'development',
48
+ 'parall.runtime_type': runtimeType,
49
+ 'parall.agent_id': process.env.PRLL_AGENT_ID || '',
50
+ 'parall.machine_id': process.env.PRLL_MACHINE_ID || '',
51
+ 'parall.org_id': process.env.PRLL_ORG_ID || '',
52
+ 'parall.daemon_mode': process.env.PRLL_DAEMON_MODE === '1',
53
+ });
54
+ const authHeaders = { Authorization: `Bearer ${apiKey}` };
55
+ const traceExporter = new OTLPTraceExporter({
56
+ url: `${otelEndpoint}/v1/traces`,
57
+ headers: authHeaders,
58
+ });
59
+ const tracerProvider = new NodeTracerProvider({ resource });
60
+ tracerProvider.addSpanProcessor(new BatchSpanProcessor(traceExporter));
61
+ tracerProvider.register();
62
+ const metricExporter = new OTLPMetricExporter({
63
+ url: `${otelEndpoint}/v1/metrics`,
64
+ headers: authHeaders,
65
+ });
66
+ const metricReader = new PeriodicExportingMetricReader({
67
+ exporter: metricExporter,
68
+ exportIntervalMillis: 15_000,
69
+ });
70
+ const meterProvider = new MeterProvider({ resource, readers: [metricReader] });
71
+ metrics.setGlobalMeterProvider(meterProvider);
72
+ const logExporter = new OTLPLogExporter({
73
+ url: `${otelEndpoint}/v1/logs`,
74
+ headers: authHeaders,
75
+ });
76
+ const loggerProvider = new LoggerProvider({ resource });
77
+ loggerProvider.addLogRecordProcessor(new BatchLogRecordProcessor(logExporter));
78
+ const meter = metrics.getMeter('parall.agent');
79
+ tracer = trace.getTracer('parall.agent');
80
+ otelLogger = loggerProvider.getLogger('parall.agent');
81
+ dispatchCounter = meter.createCounter('parall.dispatch.count', {
82
+ description: 'Number of dispatch cycles completed',
83
+ });
84
+ dispatchDuration = meter.createHistogram('parall.dispatch.duration', {
85
+ description: 'Dispatch cycle duration in milliseconds',
86
+ unit: 'ms',
87
+ });
88
+ missingReplyCounter = meter.createCounter('parall.dispatch.missing_reply', {
89
+ description: 'Dispatches where agent produced text but sent no reply message',
90
+ });
91
+ initialized = true;
92
+ shutdownFn = async () => {
93
+ await tracerProvider.forceFlush();
94
+ await meterProvider.forceFlush();
95
+ await loggerProvider.forceFlush();
96
+ await tracerProvider.shutdown();
97
+ await meterProvider.shutdown();
98
+ await loggerProvider.shutdown();
99
+ };
100
+ return {
101
+ shutdown: async () => {
102
+ if (shutdownFn)
103
+ await shutdownFn();
104
+ },
105
+ };
106
+ }
107
+ catch {
108
+ return noopHandle;
109
+ }
110
+ }
111
+ export function startDispatchSpan(event, runtimeType, sessionKey) {
112
+ if (!initialized || !tracer)
113
+ return null;
114
+ return tracer.startSpan('parall.dispatch', {
115
+ attributes: {
116
+ 'dispatch.target_type': resolveTargetType(event.targetId),
117
+ 'dispatch.event_type': event.type,
118
+ 'dispatch.runtime_type': runtimeType,
119
+ 'dispatch.session_key': sessionKey,
120
+ 'dispatch.message_id': event.messageId,
121
+ 'dispatch.target_id': event.targetId,
122
+ },
123
+ });
124
+ }
125
+ export function endDispatchSpan(span, metricsSnapshot, error) {
126
+ if (!span)
127
+ return;
128
+ if (metricsSnapshot) {
129
+ span.setAttributes({
130
+ 'dispatch.deliver_text_chunks': metricsSnapshot.deliver_text_chunks,
131
+ 'dispatch.deliver_text_chars': metricsSnapshot.deliver_text_chars,
132
+ 'dispatch.message_send_attempts': metricsSnapshot.message_send_attempts,
133
+ 'dispatch.message_send_successes': metricsSnapshot.message_send_successes,
134
+ 'dispatch.no_reply_called': metricsSnapshot.no_reply_called,
135
+ 'dispatch.tool_call_count': metricsSnapshot.tool_call_count,
136
+ 'dispatch.duration_ms': Date.now() - metricsSnapshot.started_at,
137
+ });
138
+ }
139
+ if (error) {
140
+ span.setStatus({ code: SpanStatusCode.ERROR, message: String(error) });
141
+ span.recordException(error instanceof Error ? error : new Error(String(error)));
142
+ }
143
+ span.end();
144
+ }
145
+ export function recordDispatchMetric(event, runtimeType, durationMs) {
146
+ if (!initialized)
147
+ return;
148
+ const attrs = {
149
+ target_type: resolveTargetType(event.targetId),
150
+ event_type: event.type,
151
+ runtime_type: runtimeType,
152
+ };
153
+ dispatchCounter?.add(1, attrs);
154
+ dispatchDuration?.record(durationMs, attrs);
155
+ }
156
+ export function recordMissingReply(runtimeType) {
157
+ if (!initialized)
158
+ return;
159
+ missingReplyCounter?.add(1, { runtime_type: runtimeType });
160
+ }
161
+ const sessionKeyStorage = new AsyncLocalStorage();
162
+ export function runWithSessionKey(sessionKey, fn) {
163
+ return sessionKeyStorage.run(sessionKey, fn);
164
+ }
165
+ /**
166
+ * Create a GatewayLogger that forwards all levels to OTLP logs.
167
+ * Two layers: "agent" (runtime) and "daemon".
168
+ */
169
+ export function createOtelLogger(layer, prefix) {
170
+ const ts = () => new Date().toISOString();
171
+ const emit = (severity, msg) => {
172
+ if (!otelLogger)
173
+ return;
174
+ const severityNumber = severity === 'ERROR'
175
+ ? SeverityNumber.ERROR
176
+ : severity === 'WARN'
177
+ ? SeverityNumber.WARN
178
+ : SeverityNumber.INFO;
179
+ const attrs = { 'log.layer': layer, 'log.prefix': prefix };
180
+ const sk = sessionKeyStorage.getStore();
181
+ if (sk)
182
+ attrs['session.key'] = sk;
183
+ otelLogger.emit({
184
+ severityNumber,
185
+ severityText: severity,
186
+ body: msg,
187
+ attributes: attrs,
188
+ });
189
+ };
190
+ return {
191
+ info: (msg) => {
192
+ console.log(`${ts()} [${prefix}] ${msg}`);
193
+ emit('INFO', msg);
194
+ },
195
+ warn: (msg) => {
196
+ console.warn(`${ts()} [${prefix}] ${msg}`);
197
+ emit('WARN', msg);
198
+ },
199
+ error: (msg) => {
200
+ console.error(`${ts()} [${prefix}] ${msg}`);
201
+ emit('ERROR', msg);
202
+ },
203
+ child: (sub) => createOtelLogger(layer, `${prefix}:${sub}`),
204
+ };
205
+ }
package/dist/types.d.ts CHANGED
@@ -26,10 +26,17 @@ export type DispatchState = {
26
26
  };
27
27
  /** Normalized inbound event from Parall. */
28
28
  export type ParallEvent = {
29
- type: "message" | "task" | "task_comment" | "schedule" | "approval";
29
+ type: 'message' | 'task' | 'task_comment' | 'wiki_comment' | 'schedule' | 'approval';
30
30
  targetId: string;
31
31
  targetName?: string;
32
32
  targetType?: string;
33
+ /**
34
+ * Full `prll://` target URI to reply on, used for wiki_comment events where
35
+ * the reply goes back to the same wiki page / changeset via
36
+ * `parall comments add --target <uri>`. Carried separately from `targetId`
37
+ * (a bare entity id) because the reply needs the full URI incl. path/anchor.
38
+ */
39
+ replyTargetUri?: string;
33
40
  deliveryReason?: string;
34
41
  senderId: string;
35
42
  senderName: string;
@@ -50,7 +57,16 @@ export type ParallEvent = {
50
57
  /** Original event timestamp (e.g., message.created_at). When present,
51
58
  * input steps use this instead of server insertion time for ordering. */
52
59
  sentAt?: string;
53
- ackSourceType?: "message" | "task_activity" | "comment" | "schedule_run";
60
+ ackSourceType?: 'message' | 'task_activity' | 'comment' | 'schedule_run';
54
61
  ackSourceId?: string;
62
+ /** Unread message count in the target chat since agent's last interaction. */
63
+ unreadCount?: number;
64
+ /** Channel cursor: the last message ID the agent read. */
65
+ unreadSince?: string;
66
+ /** For thread replies: total replies and unread replies in the thread. */
67
+ threadReplyCount?: number;
68
+ threadUnreadCount?: number;
69
+ /** Thread cursor: the last reply ID the agent read in this thread. */
70
+ threadUnreadSince?: string;
55
71
  };
56
72
  //# sourceMappingURL=types.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,MAAM,MAAM,UAAU,GAAG;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,mFAAmF;IACnF,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GAAG;IAC1B,eAAe,EAAE,OAAO,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kBAAkB,EAAE,UAAU,EAAE,CAAC;IACjC,UAAU,EAAE,WAAW,EAAE,CAAC;IAC1B,oFAAoF;IACpF,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,cAAc,GAAG,UAAU,GAAG,UAAU,CAAC;IACpE,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,KAAK,CAAC;QAClB,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;IACH,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;8EAC0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,SAAS,GAAG,eAAe,GAAG,SAAS,GAAG,cAAc,CAAC;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC"}
1
+ {"version":3,"file":"types.d.ts","sourceRoot":"","sources":["../src/types.ts"],"names":[],"mappings":"AAAA,2FAA2F;AAC3F,MAAM,MAAM,UAAU,GAAG;IACvB,cAAc,EAAE,MAAM,CAAC;IACvB,WAAW,EAAE;QAAE,IAAI,EAAE,MAAM,CAAC;QAAC,QAAQ,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC;IACjE,mFAAmF;IACnF,WAAW,EAAE,MAAM,EAAE,CAAC;IACtB,OAAO,EAAE,MAAM,EAAE,CAAC;IAClB,sFAAsF;IACtF,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,qFAAqF;IACrF,WAAW,CAAC,EAAE,MAAM,CAAC;CACtB,CAAC;AAEF,2DAA2D;AAC3D,MAAM,MAAM,aAAa,GAAG;IAC1B,eAAe,EAAE,OAAO,CAAC;IACzB,mBAAmB,CAAC,EAAE,MAAM,CAAC;IAC7B,WAAW,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,CAAC,CAAC;IACjC,kBAAkB,EAAE,UAAU,EAAE,CAAC;IACjC,UAAU,EAAE,WAAW,EAAE,CAAC;IAC1B,oFAAoF;IACpF,0BAA0B,CAAC,EAAE,MAAM,CAAC;CACrC,CAAC;AAEF,4CAA4C;AAC5C,MAAM,MAAM,WAAW,GAAG;IACxB,IAAI,EAAE,SAAS,GAAG,MAAM,GAAG,cAAc,GAAG,cAAc,GAAG,UAAU,GAAG,UAAU,CAAC;IACrF,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB;;;;;OAKG;IACH,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,cAAc,CAAC,EAAE,MAAM,CAAC;IACxB,QAAQ,EAAE,MAAM,CAAC;IACjB,UAAU,EAAE,MAAM,CAAC;IACnB,SAAS,EAAE,MAAM,CAAC;IAClB,IAAI,EAAE,MAAM,CAAC;IACb,YAAY,CAAC,EAAE,MAAM,CAAC;IACtB,OAAO,CAAC,EAAE,OAAO,CAAC;IAClB,WAAW,CAAC,EAAE,KAAK,CAAC;QAClB,EAAE,EAAE,MAAM,CAAC;QACX,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;QACjB,QAAQ,EAAE,MAAM,CAAC;KAClB,CAAC,CAAC;IACH,8DAA8D;IAC9D,eAAe,CAAC,EAAE,MAAM,CAAC;IACzB,oEAAoE;IACpE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB;8EAC0E;IAC1E,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB,aAAa,CAAC,EAAE,SAAS,GAAG,eAAe,GAAG,SAAS,GAAG,cAAc,CAAC;IACzE,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,8EAA8E;IAC9E,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0DAA0D;IAC1D,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,0EAA0E;IAC1E,gBAAgB,CAAC,EAAE,MAAM,CAAC;IAC1B,iBAAiB,CAAC,EAAE,MAAM,CAAC;IAC3B,sEAAsE;IACtE,iBAAiB,CAAC,EAAE,MAAM,CAAC;CAC5B,CAAC"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@parall/agent-core",
3
- "version": "1.31.0",
3
+ "version": "1.32.1",
4
4
  "description": "Shared agent runtime orchestration helpers for Parall",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -26,7 +26,16 @@
26
26
  "src"
27
27
  ],
28
28
  "dependencies": {
29
- "@parall/sdk": "1.31.0"
29
+ "@opentelemetry/api": "^1.9.0",
30
+ "@opentelemetry/api-logs": "^0.57.0",
31
+ "@opentelemetry/exporter-logs-otlp-proto": "^0.57.0",
32
+ "@opentelemetry/exporter-metrics-otlp-proto": "^0.57.0",
33
+ "@opentelemetry/exporter-trace-otlp-proto": "^0.57.0",
34
+ "@opentelemetry/resources": "^1.30.0",
35
+ "@opentelemetry/sdk-logs": "^0.57.0",
36
+ "@opentelemetry/sdk-metrics": "^1.30.0",
37
+ "@opentelemetry/sdk-trace-node": "^1.30.0",
38
+ "@parall/sdk": "1.32.1"
30
39
  },
31
40
  "devDependencies": {
32
41
  "@types/node": "^22.0.0",
@@ -50,7 +50,7 @@ See \`docs/engineering-design/agent-dm-loop-prevention.md\` § Layer 0 for why p
50
50
 
51
51
  When you try an action (e.g., archive a chat) and receive a PERMISSION_DENIED error, you can request someone with permission to do it:
52
52
 
53
- 1. The error includes a \`PERMISSION_DENIED\` code. For commonly approvable actions (archive, delete, restore), a \`Tip:\` line with an approval-request template is printed — copy it and fill in the remaining placeholders (\`--chat\`, \`--title\`, \`--reason\`)
53
+ 1. The error includes a \`PERMISSION_DENIED\` code plus the denied \`action\` and \`resource_uri\`. If the action is approvable (decided by the server — no fixed allowlist), a \`Request approval:\` line with an approval command is printed — fill in its \`--chat\`, \`--title\`, \`--reason\` placeholders and run it. If it is not approvable, the output says so; ask a human with permission instead.
54
54
  2. Request approval: \`parall approvals request --action chat.archive --resource prll://cht_123 --chat prll://cht_456 --title "Archive #old-project" --reason "Channel inactive"\`
55
55
  3. A card will appear in the specified chat for someone with permission to approve
56
56
  4. Check the result: \`parall approvals get prll://<id>\` or wait: \`parall approvals wait prll://<id> --timeout 300\`
@@ -84,9 +84,9 @@ Messages may arrive with a \`[Thread: prll://msg_xxx]\` line in the event block,
84
84
 
85
85
  /** Extracts the `command` string from a shell/bash tool call's input payload. */
86
86
  export function extractShellCommand(input: unknown): string | undefined {
87
- if (!input || typeof input !== "object") return undefined;
87
+ if (!input || typeof input !== 'object') return undefined;
88
88
  const command = (input as { command?: unknown }).command;
89
- return typeof command === "string" && command.trim() ? command.trim() : undefined;
89
+ return typeof command === 'string' && command.trim() ? command.trim() : undefined;
90
90
  }
91
91
 
92
92
  /**
@@ -101,24 +101,24 @@ export function extractShellCommand(input: unknown): string | undefined {
101
101
  * `no-reply` subcommand).
102
102
  */
103
103
  export function parseParallCliInvocation(command: string): string[] | null {
104
- const tokens = command.replace(/\s+/g, " ").trim().split(" ");
104
+ const tokens = command.replace(/\s+/g, ' ').trim().split(' ');
105
105
  let i = 0;
106
- if (tokens[i] === "parall") {
106
+ if (tokens[i] === 'parall') {
107
107
  i++;
108
- } else if (tokens[i] === "npx") {
108
+ } else if (tokens[i] === 'npx') {
109
109
  i++;
110
- while (i < tokens.length && tokens[i].startsWith("-")) i++;
110
+ while (i < tokens.length && tokens[i].startsWith('-')) i++;
111
111
  if (i >= tokens.length || !/^@parall\/cli(?:@.+)?$/.test(tokens[i])) return null;
112
112
  i++;
113
- } else if (tokens[i] === "pnpm") {
113
+ } else if (tokens[i] === 'pnpm') {
114
114
  i++;
115
- if (i < tokens.length && (tokens[i] === "exec" || tokens[i] === "dlx")) i++;
116
- if (i >= tokens.length || tokens[i] !== "parall") return null;
115
+ if (i < tokens.length && (tokens[i] === 'exec' || tokens[i] === 'dlx')) i++;
116
+ if (i >= tokens.length || tokens[i] !== 'parall') return null;
117
117
  i++;
118
118
  } else {
119
119
  return null;
120
120
  }
121
- return tokens.slice(i).filter((t) => !t.startsWith("-"));
121
+ return tokens.slice(i).filter((t) => !t.startsWith('-'));
122
122
  }
123
123
 
124
124
  /**
@@ -132,7 +132,7 @@ export function isParallSendCommand(command: string | undefined): boolean {
132
132
  if (!command) return false;
133
133
  const sub = parseParallCliInvocation(command);
134
134
  if (!sub || sub.length === 0) return false;
135
- return sub[0] === "dm" || (sub[0] === "messages" && sub[1] === "send");
135
+ return sub[0] === 'dm' || (sub[0] === 'messages' && sub[1] === 'send');
136
136
  }
137
137
 
138
138
  /**
@@ -144,5 +144,5 @@ export function isParallSendCommand(command: string | undefined): boolean {
144
144
  export function isParallNoReplyCommand(command: string | undefined): boolean {
145
145
  if (!command) return false;
146
146
  const sub = parseParallCliInvocation(command);
147
- return sub?.[0] === "no-reply";
147
+ return sub?.[0] === 'no-reply';
148
148
  }
@@ -1,5 +1,5 @@
1
- import type { ParallClient } from "@parall/sdk";
2
- import type { ParallEvent } from "./types.js";
1
+ import type { ParallClient } from '@parall/sdk';
2
+ import type { ParallEvent } from './types.js';
3
3
 
4
4
  export type GatewayLogger = {
5
5
  info: (msg: string) => void;
@@ -29,16 +29,31 @@ export type DispatchContext = {
29
29
 
30
30
  export type RuntimeEvent =
31
31
  | {
32
- type: "runtime_session";
32
+ type: 'runtime_session';
33
33
  runtimeSessionId: string;
34
34
  runtimeLaneKey?: string;
35
35
  runtimeRef?: Record<string, unknown>;
36
36
  }
37
- | { type: "thinking"; text: string; groupKey?: string }
38
- | { type: "tool_call"; callId: string; toolName: string; input: unknown; startedAt?: string; groupKey?: string }
39
- | { type: "tool_result"; callId: string; toolName: string; output: string; error?: string; durationMs?: number; groupKey?: string }
40
- | { type: "text"; text: string; project?: boolean; groupKey?: string }
41
- | { type: "error"; message: string };
37
+ | { type: 'thinking'; text: string; groupKey?: string }
38
+ | {
39
+ type: 'tool_call';
40
+ callId: string;
41
+ toolName: string;
42
+ input: unknown;
43
+ startedAt?: string;
44
+ groupKey?: string;
45
+ }
46
+ | {
47
+ type: 'tool_result';
48
+ callId: string;
49
+ toolName: string;
50
+ output: string;
51
+ error?: string;
52
+ durationMs?: number;
53
+ groupKey?: string;
54
+ }
55
+ | { type: 'text'; text: string; project?: boolean; groupKey?: string }
56
+ | { type: 'error'; message: string };
42
57
 
43
58
  export type DispatchOpts = {
44
59
  event: ParallEvent;
@@ -98,4 +113,12 @@ export interface DispatchAdapter {
98
113
 
99
114
  /** Return the path to the session's history file on disk, if the runtime persists it. */
100
115
  getSessionHistoryPath?(sessionKey: string): string | undefined;
116
+
117
+ /**
118
+ * Abort the in-flight dispatch for the given session. Called by the gateway
119
+ * when the dispatch deadline is exceeded. Implementations should unblock the
120
+ * `dispatch()` generator (e.g. close a turn sink, end stdin, fail a stream)
121
+ * so the `for await` loop in `runDispatch` exits naturally. Must be idempotent.
122
+ */
123
+ abortDispatch?(sessionKey: string): void;
101
124
  }
@@ -1,31 +1,58 @@
1
- import type { ForkResult, ParallEvent } from "./types.js";
1
+ import type { ForkResult, ParallEvent } from './types.js';
2
2
 
3
3
  function sanitizeMeta(value: string): string {
4
- return value.replace(/[\r\n]+/g, " ").replace(/[[\]|]/g, " ").trim();
4
+ return value
5
+ .replace(/[\r\n]+/g, ' ')
6
+ .replace(/[[\]|]/g, ' ')
7
+ .trim();
5
8
  }
6
9
 
7
10
  export function buildEventBody(event: ParallEvent): string {
8
11
  const lines: string[] = [];
9
- if (event.type === "message") {
12
+ if (event.type === 'message') {
10
13
  lines.push(`[Event: message.new]`);
11
14
  const chatLabel = event.targetName
12
15
  ? `"${event.targetName}" (prll://${event.targetId})`
13
16
  : `prll://${event.targetId}`;
14
- lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? "unknown"}]`);
17
+ lines.push(`[Chat: ${chatLabel} | type: ${event.targetType ?? 'unknown'}]`);
15
18
  lines.push(`[From: ${event.senderName} (prll://${event.senderId})]`);
16
19
  lines.push(`[Message ID: prll://${event.messageId}]`);
17
- if (event.threadRootId) lines.push(`[Thread: prll://${event.threadRootId}]`);
20
+ if (event.threadRootId) {
21
+ const threadMeta = [
22
+ `prll://${event.threadRootId}`,
23
+ event.threadReplyCount != null ? `${event.threadReplyCount} replies` : null,
24
+ event.threadUnreadCount != null && event.threadUnreadCount > 0
25
+ ? `${event.threadUnreadCount} unread`
26
+ : null,
27
+ event.threadUnreadCount != null && event.threadUnreadCount > 0 && event.threadUnreadSince
28
+ ? `since: prll://${event.threadUnreadSince}`
29
+ : null,
30
+ ]
31
+ .filter(Boolean)
32
+ .join(' | ');
33
+ lines.push(`[Thread: ${threadMeta}]`);
34
+ }
35
+ if (event.unreadCount != null && event.unreadCount > 1) {
36
+ const countStr = event.unreadCount >= 1000 ? '999+' : String(event.unreadCount);
37
+ const sinceStr = event.unreadSince ? ` | since: prll://${event.unreadSince}` : '';
38
+ let line = `[Unread: ${countStr} messages${sinceStr}]`;
39
+ if (event.unreadCount > 50) line += ` — fetch recent context with --limit, not all`;
40
+ lines.push(line);
41
+ }
18
42
  if (event.noReply) lines.push(`[Hint: no_reply]`);
19
43
  if (event.attachments?.length) {
20
44
  for (const att of event.attachments) {
21
- const sizeStr = att.fileSize >= 1048576
22
- ? `${(att.fileSize / 1048576).toFixed(1)}MB`
23
- : `${Math.round(att.fileSize / 1024)}KB`;
24
- lines.push(`[Attachment: prll://${att.id} | ${sanitizeMeta(att.mimeType)} | ${sizeStr} | ${sanitizeMeta(att.fileName)}]`);
45
+ const sizeStr =
46
+ att.fileSize >= 1048576
47
+ ? `${(att.fileSize / 1048576).toFixed(1)}MB`
48
+ : `${Math.round(att.fileSize / 1024)}KB`;
49
+ lines.push(
50
+ `[Attachment: prll://${att.id} | ${sanitizeMeta(att.mimeType)} | ${sizeStr} | ${sanitizeMeta(att.fileName)}]`,
51
+ );
25
52
  }
26
53
  }
27
- lines.push("", event.body);
28
- } else if (event.type === "task_comment") {
54
+ lines.push('', event.body);
55
+ } else if (event.type === 'task_comment') {
29
56
  lines.push(`[Event: task.comment.created]`);
30
57
  const taskLabel = event.targetName
31
58
  ? `${event.targetName} (prll://${event.targetId})`
@@ -34,20 +61,34 @@ export function buildEventBody(event: ParallEvent): string {
34
61
  if (event.deliveryReason) lines.push(`[Delivery: ${sanitizeMeta(event.deliveryReason)}]`);
35
62
  lines.push(`[From: ${event.senderName} (prll://${event.senderId})]`);
36
63
  lines.push(`[Comment ID: prll://${event.messageId}]`);
37
- lines.push("", event.body);
38
- } else if (event.type === "approval") {
64
+ lines.push('', event.body);
65
+ } else if (event.type === 'wiki_comment') {
66
+ lines.push(`[Event: wiki.comment.created]`);
67
+ const target = event.replyTargetUri ?? `prll://${event.targetId}`;
68
+ if (event.targetType === 'changeset') {
69
+ lines.push(`[Wiki Changeset: ${target}]`);
70
+ } else {
71
+ lines.push(
72
+ `[Wiki: ${event.targetName ? `${sanitizeMeta(event.targetName)} (${target})` : target}]`,
73
+ );
74
+ }
75
+ if (event.deliveryReason) lines.push(`[Delivery: ${sanitizeMeta(event.deliveryReason)}]`);
76
+ lines.push(`[From: ${event.senderName} (prll://${event.senderId})]`);
77
+ lines.push(`[Comment ID: prll://${event.messageId}]`);
78
+ lines.push('', event.body);
79
+ } else if (event.type === 'approval') {
39
80
  lines.push(`[Event: approval.decided]`);
40
81
  lines.push(`[Approval: prll://${event.messageId}]`);
41
82
  lines.push(`[Chat: prll://${event.targetId}]`);
42
83
  lines.push(`[Decided by: ${event.senderName} (prll://${event.senderId})]`);
43
- lines.push("", event.body);
44
- } else if (event.type === "schedule") {
84
+ lines.push('', event.body);
85
+ } else if (event.type === 'schedule') {
45
86
  lines.push(`[Event: schedule.fired]`);
46
87
  lines.push(`[Schedule: prll://${event.targetId}]`);
47
88
  lines.push(`[Run: prll://${event.messageId}]`);
48
89
  if (event.scheduledFireAt) lines.push(`[Scheduled at: ${sanitizeMeta(event.scheduledFireAt)}]`);
49
90
  if (event.attachedUri) lines.push(`[Attached: ${sanitizeMeta(event.attachedUri)}]`);
50
- lines.push("", event.body);
91
+ lines.push('', event.body);
51
92
  } else {
52
93
  lines.push(`[Event: task.assigned]`);
53
94
  const taskLabel = event.targetName
@@ -55,31 +96,36 @@ export function buildEventBody(event: ParallEvent): string {
55
96
  : `prll://${event.targetId}`;
56
97
  lines.push(`[Task: ${taskLabel}]`);
57
98
  lines.push(`[Assigned by: ${event.senderName} (prll://${event.senderId})]`);
58
- lines.push("", event.body);
99
+ lines.push('', event.body);
59
100
  }
60
- return lines.join("\n") + buildSendMessageHint(event);
101
+ return lines.join('\n') + buildSendMessageHint(event);
61
102
  }
62
103
 
63
104
  export function buildEventBodyForForkResult(event: ParallEvent): string {
64
- return buildEventBody(event).replace(/\n<system-reminder>[\s\S]*<\/system-reminder>$/, "");
105
+ return buildEventBody(event).replace(/\n<system-reminder>[\s\S]*<\/system-reminder>$/, '');
65
106
  }
66
107
 
67
108
  function buildSendMessageHint(event: ParallEvent): string {
68
- if (event.noReply) return "";
109
+ if (event.noReply) return '';
110
+
111
+ if (event.type === 'wiki_comment' && event.replyTargetUri) {
112
+ const where = event.targetType === 'changeset' ? 'this changeset comment' : 'this wiki page';
113
+ return `\n<system-reminder>To reply on ${where}, run: \`parall comments add --target "${event.replyTargetUri}" --body "..."\` (read the thread first with \`parall comments list --target "${event.replyTargetUri}"\`). To message someone instead, use \`parall messages send\` / \`parall dm\`. Your plain text output is not delivered.</system-reminder>`;
114
+ }
69
115
 
70
- if (event.targetId.startsWith("cht_")) {
116
+ if (event.targetId.startsWith('cht_')) {
71
117
  return `\n<system-reminder>To reply, run: \`parall messages send prll://${event.targetId} --text "..."\` — your plain text output is not delivered to the chat.</system-reminder>`;
72
118
  }
73
119
 
74
- if (event.targetId.startsWith("tsk_")) {
120
+ if (event.targetId.startsWith('tsk_')) {
75
121
  return `\n<system-reminder>To respond, use the CLI: \`parall tasks update\` / \`parall tasks comments add\`. To message someone, use \`parall messages send\` / \`parall dm\`. Your plain text output is not delivered.</system-reminder>`;
76
122
  }
77
123
 
78
- if (event.targetId.startsWith("sch_")) {
124
+ if (event.targetId.startsWith('sch_')) {
79
125
  return `\n<system-reminder>To communicate, use the CLI: \`parall messages send\` / \`parall dm\`. Your plain text output is not delivered.</system-reminder>`;
80
126
  }
81
127
 
82
- return "";
128
+ return '';
83
129
  }
84
130
 
85
131
  export function buildForkScopePrefix(event: ParallEvent): string {
@@ -90,17 +136,21 @@ export function buildForkScopePrefix(event: ParallEvent): string {
90
136
  }
91
137
 
92
138
  export function buildForkResultPrefix(results: ForkResult[]): string {
93
- if (!results.length) return "";
139
+ if (!results.length) return '';
94
140
  const blocks = results.map((result) => {
95
141
  const lines: string[] = [];
96
142
  for (const body of result.eventBodies) {
97
143
  lines.push(body);
98
144
  }
99
- lines.push(`[This event was handled by a parallel fork session. Do NOT re-handle, re-reply, or duplicate work for it.]`);
100
- lines.push(`[Fork summary: ${result.agentSummary ? sanitizeMeta(result.agentSummary) : "No fork summary available the fork completed without producing a text summary. Check the target chat/task for any actions the fork already took before acting."}]`);
101
- if (result.actions.length) lines.push(`[Fork actions: ${result.actions.join("; ")}]`);
145
+ lines.push(
146
+ `[This event was handled by a parallel fork session. Do NOT re-handle, re-reply, or duplicate work for it.]`,
147
+ );
148
+ lines.push(
149
+ `[Fork summary: ${result.agentSummary ? sanitizeMeta(result.agentSummary) : 'No fork summary available — the fork completed without producing a text summary. Check the target chat/task for any actions the fork already took before acting.'}]`,
150
+ );
151
+ if (result.actions.length) lines.push(`[Fork actions: ${result.actions.join('; ')}]`);
102
152
  if (result.historyPath) lines.push(`[Fork history: ${result.historyPath}]`);
103
- return lines.join("\n");
153
+ return lines.join('\n');
104
154
  });
105
- return blocks.join("\n\n") + "\n\n---\n\n";
155
+ return blocks.join('\n\n') + '\n\n---\n\n';
106
156
  }