granttap-mcp 0.8.115 → 0.8.116

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.
@@ -2737,3 +2737,33 @@ ANY SPECIAL, DIRECT, INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES
2737
2737
  WHATSOEVER RESULTING FROM LOSS OF USE, DATA OR PROFITS, WHETHER IN AN
2738
2738
  ACTION OF CONTRACT, NEGLIGENCE OR OTHER TORTIOUS ACTION, ARISING OUT OF
2739
2739
  OR IN CONNECTION WITH THE USE OR PERFORMANCE OF THIS SOFTWARE.
2740
+
2741
+
2742
+ smol-toml 1.9.1 — BSD-3-Clause
2743
+ Source: https://www.npmjs.com/package/smol-toml/v/1.9.1
2744
+ Notices: fa5659948374d4f555594f47f6da073b40dc503e921aeeece30df4362b3051a5
2745
+
2746
+ Copyright (c) Squirrel Chat et al., All rights reserved.
2747
+
2748
+ Redistribution and use in source and binary forms, with or without
2749
+ modification, are permitted provided that the following conditions are met:
2750
+
2751
+ 1. Redistributions of source code must retain the above copyright notice, this
2752
+ list of conditions and the following disclaimer.
2753
+ 2. Redistributions in binary form must reproduce the above copyright notice,
2754
+ this list of conditions and the following disclaimer in the
2755
+ documentation and/or other materials provided with the distribution.
2756
+ 3. Neither the name of the copyright holder nor the names of its contributors
2757
+ may be used to endorse or promote products derived from this software without
2758
+ specific prior written permission.
2759
+
2760
+ THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
2761
+ ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
2762
+ WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
2763
+ DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE
2764
+ FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL
2765
+ DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR
2766
+ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER
2767
+ CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY,
2768
+ OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE
2769
+ OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
@@ -20,6 +20,7 @@ boundaries and upstream grants remain separate from GrantTap's first-party comme
20
20
  | `tweetnacl` | The Unlicense |
21
21
  | `ws` | MIT |
22
22
  | `zod` | MIT |
23
+ | `smol-toml` | BSD-3-Clause |
23
24
 
24
25
  The unpublished Rust workspace also depends on separately licensed components,
25
26
  including `mcport` (MIT), `blazingly-json` (MIT), `serde` (MIT OR Apache-2.0), and
@@ -83,6 +83,7 @@ async function sendScopedPayload(input: {
83
83
  key,
84
84
  purpose,
85
85
  createdAt: Date.now(),
86
- }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey, ...grantOptions });
86
+ }, to, { ttlMs: options.ttlMs, peerPublicKey: options.peerPublicKey,
87
+ ...(options.urgent ? { urgent: true } : {}), ...grantOptions });
87
88
  await client.sendSession(payload, sessionId, to, options);
88
89
  }
@@ -1,5 +1,6 @@
1
1
  import { handleTaskTransferCommand } from "../../task-transfer/runtime";
2
2
  import { handleDeviceManagement } from '../../device-management';
3
+ import { handleTaskSchedules } from '../../task-schedules/relay';
3
4
  import { handleProjectTransportConfiguration } from '../../task-network/runtime';
4
5
  import { handleHookTrust } from "../../codex-hook-trust";
5
6
  import { handleConfigurationCommand } from "../../configuration-delivery";
@@ -69,6 +70,7 @@ export async function handleMonitorMessage(
69
70
  if (!leadership.acquire()) return false;
70
71
  const configuration = handlePeerConfiguration(client, payload, input.peerPublicKey, publish);
71
72
  if (configuration) return configuration;
73
+ if (payload.type === 'task.schedules') return handleTaskSchedules(client, payload, input.peerPublicKey);
72
74
  if (payload.type === "knowledge.write") {
73
75
  return handleInboundKnowledgeWrite(client, payload, input.peerPublicKey, publish);
74
76
  }
@@ -20,7 +20,8 @@ export const HEARTBEAT_INTERVAL_MS = Number(
20
20
  */
21
21
  export async function publishHeartbeat(client: RelayClient): Promise<void> {
22
22
  await client.send(
23
- { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true },
23
+ { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true,
24
+ taskScheduleControls: true },
24
25
  "phone",
25
26
  { ttlMs: HEARTBEAT_INTERVAL_MS * 3, reliable: false },
26
27
  );
@@ -38,7 +39,8 @@ export const HELD_HEARTBEAT_TTL_MS = 60_000;
38
39
 
39
40
  export async function publishHeldHeartbeat(client: RelayClient): Promise<void> {
40
41
  await client.send(
41
- { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true },
42
+ { type: "machine.heartbeat", machine: computerDisplayName(), createdAt: Date.now(), taskQueueImmediate: true,
43
+ taskScheduleControls: true },
42
44
  "phone",
43
45
  { ttlMs: HELD_HEARTBEAT_TTL_MS, reliable: true },
44
46
  );
@@ -0,0 +1,8 @@
1
+ # Grok catalog cache
2
+
3
+ Public entry: `GrokCatalogCache`. Cache only the parsed native inputs belonging
4
+ to the current admitted scan. Model-context files and visible update streams
5
+ have separate fingerprints, so streaming text does not decode an unchanged
6
+ context again. A complete export bypasses this catalog cache.
7
+
8
+ First-party code is under the GrantTap Commercial Source License.
@@ -0,0 +1,74 @@
1
+ import { statSync } from 'node:fs';
2
+ import { join, resolve } from 'node:path';
3
+ import type { ActivityEntry } from '../../../../../packages/protocol/schema';
4
+ import type { CapabilityObservation } from '../../sessions/telemetry';
5
+
6
+ export type GrokParsedContext = { entries: ActivityEntry[]; usage: CapabilityObservation[];
7
+ firstUser?: string; lastMessageAt?: number; tokens: number; lastTurn: number; cacheable?: boolean };
8
+ type Source = { fingerprint: string; present: boolean };
9
+ type Entry = { contextFingerprint: string; updateFingerprint?: string; context: GrokParsedContext;
10
+ omittedContextEntries: boolean; visible?: ActivityEntry[]; result: GrokParsedContext };
11
+ type Identity = { directory: string; sessionId: string; fallbackTime: number };
12
+
13
+ function source(identity: Identity, names: string[]): Source | undefined {
14
+ let present = false;
15
+ const stamps: string[] = [identity.sessionId, String(identity.fallbackTime)];
16
+ try {
17
+ for (const name of names) {
18
+ try {
19
+ const stat = statSync(join(identity.directory, name), { bigint: true });
20
+ present = true;
21
+ stamps.push([name, stat.dev, stat.ino, stat.size, stat.mtimeNs, stat.ctimeNs].map(String).join(':'));
22
+ } catch (error) {
23
+ if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error;
24
+ stamps.push(name + ':missing');
25
+ }
26
+ }
27
+ return { fingerprint: JSON.stringify(stamps), present };
28
+ } catch { return undefined; }
29
+ }
30
+
31
+ export function withGrokVisible(context: GrokParsedContext, visible: ActivityEntry[] | undefined): GrokParsedContext {
32
+ if (visible === undefined) return context;
33
+ let firstUser = context.firstUser, lastMessageAt = context.lastMessageAt ?? 0;
34
+ for (const entry of visible) {
35
+ if (entry.kind === 'user') firstUser ??= entry.text.trim().slice(0, 120);
36
+ if (['user', 'message', 'final'].includes(entry.kind)) lastMessageAt = Math.max(lastMessageAt, entry.createdAt);
37
+ }
38
+ return { ...context, entries: visible, firstUser, lastMessageAt: lastMessageAt || undefined };
39
+ }
40
+
41
+ /** Retain only the current scan's sources; context and the native visible stream advance independently. */
42
+ export class GrokCatalogCache {
43
+ private readonly entries = new Map<string, Entry>();
44
+
45
+ retain(directories: ReadonlySet<string>): void {
46
+ const keep = new Set([...directories].map(path => resolve(path)));
47
+ for (const key of this.entries.keys()) if (!keep.has(key)) this.entries.delete(key);
48
+ }
49
+
50
+ read(identity: Identity, load: { context: () => GrokParsedContext; visible: () => ActivityEntry[] | undefined }): GrokParsedContext {
51
+ const key = resolve(identity.directory), previous = this.entries.get(key);
52
+ const contextSource = source(identity, ['chat_history.jsonl', 'events.jsonl', 'rewind_points.jsonl']);
53
+ const updateSource = source(identity, ['updates.jsonl']);
54
+ const sameContext = contextSource != null && previous?.contextFingerprint === contextSource.fingerprint;
55
+ const sameUpdates = updateSource != null && previous?.updateFingerprint === updateSource.fingerprint;
56
+ if (sameContext && sameUpdates) return previous!.result;
57
+ const loadedVisible = sameUpdates ? undefined : load.visible();
58
+ const visible = sameUpdates ? previous!.visible
59
+ : loadedVisible === undefined ? undefined : structuredClone(loadedVisible);
60
+ const context = sameContext && !(visible === undefined && previous!.omittedContextEntries)
61
+ ? previous!.context : load.context();
62
+ const result = withGrokVisible(context, visible);
63
+ const unchangedContext = contextSource != null && contextSource.fingerprint
64
+ === source(identity, ['chat_history.jsonl', 'events.jsonl', 'rewind_points.jsonl'])?.fingerprint;
65
+ const unchangedUpdates = updateSource != null && updateSource.fingerprint === source(identity, ['updates.jsonl'])?.fingerprint;
66
+ if (unchangedContext && unchangedUpdates && context.cacheable !== false) {
67
+ this.entries.set(key, { contextFingerprint: contextSource!.fingerprint,
68
+ updateFingerprint: visible !== undefined || !updateSource!.present ? updateSource!.fingerprint : undefined,
69
+ context: visible !== undefined ? { ...context, entries: [] } : context,
70
+ omittedContextEntries: visible !== undefined, visible, result });
71
+ }
72
+ return result;
73
+ }
74
+ }
@@ -21,6 +21,7 @@ import { grokNativeActivity } from '../../providers/grok-activity';
21
21
  import { grokTimeline } from '../../providers/grok-timeline';
22
22
  import { grokToolCalls, grokToolCompletions, grokToolSummary, grokRecordedChanges } from '../../providers/grok-tools';
23
23
  import { readGrokConversation } from '../../providers/grok-conversation';
24
+ import { GrokCatalogCache, type GrokParsedContext } from '../../providers/grok-catalog-cache';
24
25
 
25
26
  type Summary = {
26
27
  info?: { id?: unknown; cwd?: unknown };
@@ -31,15 +32,7 @@ type Summary = {
31
32
  current_model_id?: unknown;
32
33
  };
33
34
 
34
- type ParsedLog = {
35
- entries: ActivityEntry[];
36
- usage: CapabilityObservation[];
37
- firstUser?: string;
38
- lastMessageAt?: number;
39
- tokens: number;
40
- lastTurn: number;
41
- };
42
-
35
+ const catalogCache = new GrokCatalogCache();
43
36
  let activityBySession = new Map<string, ActivityEntry[]>();
44
37
  let logPaths = new Map<string, string>();
45
38
  let usageBySession = new Map<string, CapabilityObservation[]>();
@@ -84,9 +77,14 @@ function visibleRole(row: Record<string, unknown>, message: Record<string, unkno
84
77
  return typeof type === "string" ? type : "";
85
78
  }
86
79
 
87
- function parseLog(path: string, sessionId: string, fallbackTime: number, fullText = false): ParsedLog {
80
+ function parseLog(path: string, sessionId: string, fallbackTime: number, fullText = false): GrokParsedContext {
88
81
  let lines: string[];
89
- try { lines = readFileSync(path, "utf8").split("\n"); } catch { lines = []; }
82
+ let cacheable = true;
83
+ try { lines = readFileSync(path, "utf8").split("\n"); }
84
+ catch (error) {
85
+ if (fullText && (error as NodeJS.ErrnoException).code !== 'ENOENT') throw Error('The complete Grok conversation could not be read.');
86
+ lines = []; cacheable = (error as NodeJS.ErrnoException).code === 'ENOENT';
87
+ }
90
88
  const entries: ActivityEntry[] = [];
91
89
  const usage: CapabilityObservation[] = [];
92
90
  const seen = new Set<string>();
@@ -99,7 +97,10 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
99
97
  let prompt: { at: number; row: number } | undefined;
100
98
  lines.forEach((line, index) => {
101
99
  const row = safeParse(line);
102
- if (!row) return;
100
+ if (!row) {
101
+ if (fullText && line.trim()) throw Error('The full native Grok conversation is incomplete or still being written.');
102
+ return;
103
+ }
103
104
  const message = row.message && typeof row.message === "object" ? row.message : row;
104
105
  const role = visibleRole(row, message);
105
106
  if (role === "reasoning" || role === "system") return;
@@ -118,7 +119,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
118
119
  const text = texts.join("\n").trim();
119
120
  if (text) {
120
121
  if (messageAt || prompt) lastMessageAt = Math.max(lastMessageAt, createdAt);
121
- firstUser ??= text;
122
+ firstUser ??= text.slice(0, 120);
122
123
  pushEntry({ fullText, out: entries, seen, sessionId, kind: "user", text, createdAt, ordinal: index,
123
124
  idOverride: `${sessionId}:${legacyTime}:${index}` });
124
125
  tokens += estimateTokens(text);
@@ -162,13 +163,7 @@ function parseLog(path: string, sessionId: string, fallbackTime: number, fullTex
162
163
  last.kind = 'final'; last.createdAt = timeline.completedAt;
163
164
  lastMessageAt = Math.max(lastMessageAt, timeline.completedAt);
164
165
  }
165
- const visible = readGrokConversation(dirname(path), sessionId, fullText) ?? entries;
166
- for (const entry of visible) {
167
- if (entry.kind === 'user') firstUser ??= entry.text;
168
- if (['user', 'message', 'final'].includes(entry.kind)) lastMessageAt = Math.max(lastMessageAt, entry.createdAt);
169
- }
170
- return { entries: visible,
171
- usage, firstUser, lastMessageAt: lastMessageAt || undefined, tokens, lastTurn };
166
+ return { entries, usage, firstUser, lastMessageAt: lastMessageAt || undefined, tokens, lastTurn, cacheable };
172
167
  }
173
168
 
174
169
  function parseSummary(path: string): Summary | null {
@@ -181,6 +176,10 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
181
176
  const usages = new Map<string, CapabilityObservation[]>();
182
177
  let tokensRecent = 0;
183
178
  const listed = recentSummaries(grokSessionsRoot(), maxFiles + 1);
179
+ logPaths = new Map();
180
+ const selected = new Set(listed.slice(0, maxFiles).map(path => dirname(path)));
181
+ catalogCache.retain(selected);
182
+ const admitted = new Set<string>();
184
183
  for (const path of listed.slice(0, maxFiles)) {
185
184
  const summary = parseSummary(path);
186
185
  if (!summary) continue;
@@ -188,13 +187,17 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
188
187
  const sessionId = typeof summary.info?.id === "string"
189
188
  ? summary.info.id : basename(directory);
190
189
  if (!sessionId || sessionId.length > 256) continue;
190
+ admitted.add(directory);
191
191
  let stat;
192
192
  try { stat = statSync(join(directory, "chat_history.jsonl")); } catch {
193
193
  try { stat = statSync(path); } catch { continue; }
194
194
  }
195
195
  const updatedAt = ts(summary.updated_at) || stat.mtimeMs;
196
196
  const startedAt = ts(summary.created_at) || stat.birthtimeMs || updatedAt;
197
- const parsed = parseLog(join(directory, "chat_history.jsonl"), sessionId, startedAt);
197
+ const parsed = catalogCache.read({ directory, sessionId, fallbackTime: startedAt }, {
198
+ context: () => parseLog(join(directory, 'chat_history.jsonl'), sessionId, startedAt),
199
+ visible: () => readGrokConversation(directory, sessionId),
200
+ });
198
201
  const title = [summary.generated_title, summary.session_summary, parsed.firstUser]
199
202
  .find((value): value is string => typeof value === "string" && value.trim().length > 0);
200
203
  const cwd = typeof summary.info?.cwd === "string" ? summary.info.cwd : undefined;
@@ -213,6 +216,7 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
213
216
  if (Date.now() - updatedAt <= TOKEN_WINDOW_MS) tokensRecent += parsed.tokens;
214
217
  }
215
218
  activityBySession = activities;
219
+ catalogCache.retain(admitted);
216
220
  usageBySession = usages;
217
221
  return { sessions, tokensRecent, sourceLimited: listed.length > maxFiles };
218
222
  }
@@ -220,7 +224,11 @@ export function scanGrok(maxFiles = MAX_FILES): Scan {
220
224
  export function grokActivity(session: SessionInfo, fullText = false): ActivityEntry[] {
221
225
  if (!activityBySession.has(session.sessionId)) scanGrok();
222
226
  const path = logPaths.get(session.sessionId);
223
- if (fullText && path) return parseLog(path, session.sessionId, session.startedAt, true).entries;
227
+ if (fullText && path) {
228
+ const native = readGrokConversation(dirname(path), session.sessionId, true);
229
+ if (native !== undefined) return structuredClone(native);
230
+ return parseLog(path, session.sessionId, session.startedAt, true).entries;
231
+ }
224
232
  return activityBySession.get(session.sessionId) ?? [];
225
233
  }
226
234
 
@@ -0,0 +1,23 @@
1
+ # Task schedules
2
+
3
+ Public entry: `handleTaskSchedules`. The paired controller lists and edits existing
4
+ Codex heartbeat automations attached to admitted executions of the same Task and
5
+ Project. GrantTap does not create a scheduler or start provider turns here.
6
+
7
+ The local Codex version-1 mirror and native scheduling row must agree. Updates
8
+ preserve unknown fields and execution settings, use an expected revision, retain
9
+ private before/proposed checkpoints, and roll back the mirror if the native
10
+ transaction fails. Cloud migration, ambiguous databases, unsupported storage and
11
+ native edits make controls read-only. Pause removes future scheduling without
12
+ cancelling a running turn. Intervals use elapsed time; wall-clock rules use the
13
+ computer's time zone. There is no background polling.
14
+
15
+ Requests expire, repeat identities cannot repeat mutations, and replies go only
16
+ to the requesting paired controller under the chat's key. Content and identifiers
17
+ are bounded. Checkpoints remain private computer data, not disposable cache.
18
+
19
+ Tests beside this entry exercise native persistence, rollback, external changes,
20
+ scope, authenticated routing, lost receipts and repeated taps using isolated stores.
21
+
22
+ License: first-party code is under the GrantTap Commercial Source License in the repository LICENSE.
23
+ Third-party dependencies retain their own licenses and notices.
@@ -0,0 +1,9 @@
1
+ # Native schedule cadence
2
+
3
+ Public entry: `validateScheduleRule` and `nextNativeOccurrence`. Accept the exact
4
+ minute/hour interval and daily/weekly wall-clock forms editable by the phone.
5
+ Compute only the next native timestamp when explicitly enabling or changing a
6
+ schedule. This module owns no timer and never executes a Task. The computer's
7
+ civil time handles daylight-saving changes; elapsed intervals remain durations.
8
+
9
+ First-party code is under the GrantTap Commercial Source License.
@@ -0,0 +1,43 @@
1
+ type Schedule = { frequency: string; interval: number; hour: number; minute: number; days: Set<string> };
2
+
3
+ function parse(rule: string): Schedule | undefined {
4
+ const pairs = rule.replace(/^RRULE:/, '').split(';').map(part => part.split('='));
5
+ if (pairs.some(pair => pair.length !== 2)) return undefined;
6
+ const fields = new Map(pairs.map(([key, value]) => [key!, value!]));
7
+ if (fields.size !== pairs.length) return undefined;
8
+ const frequency = fields.get('FREQ') ?? '', intervalText = fields.get('INTERVAL') ?? '1';
9
+ if (!/^[1-9]\d{0,3}$/.test(intervalText)) return undefined;
10
+ const interval = Number(intervalText);
11
+ if (['MINUTELY', 'HOURLY'].includes(frequency)) {
12
+ return [...fields.keys()].every(key => ['FREQ', 'INTERVAL'].includes(key))
13
+ ? { frequency, interval, hour: 0, minute: 0, days: new Set() } : undefined;
14
+ }
15
+ if (!['DAILY', 'WEEKLY'].includes(frequency) || interval !== 1
16
+ || [...fields.keys()].some(key => !['FREQ', 'INTERVAL', 'BYHOUR', 'BYMINUTE', 'BYDAY'].includes(key))) return undefined;
17
+ const hourText = fields.get('BYHOUR') ?? '', minuteText = fields.get('BYMINUTE') ?? '';
18
+ if (!/^\d{1,2}$/.test(hourText) || Number(hourText) > 23 || !/^\d{1,2}$/.test(minuteText) || Number(minuteText) > 59) return undefined;
19
+ const dayValues = fields.has('BYDAY') ? fields.get('BYDAY')!.split(',') : [], days = new Set(dayValues);
20
+ if (frequency === 'DAILY' && fields.has('BYDAY')) return undefined;
21
+ if (frequency === 'WEEKLY' && (days.size === 0 || days.size !== dayValues.length
22
+ || [...days].some(day => !['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'].includes(day)))) return undefined;
23
+ return { frequency, interval, hour: Number(hourText), minute: Number(minuteText), days };
24
+ }
25
+
26
+ export function validateScheduleRule(rule: string): boolean { return parse(rule) !== undefined; }
27
+
28
+ /** One next native timestamp, not a scheduler. Civil dates keep daily/weekly times through DST. */
29
+ export function nextNativeOccurrence(rule: string, now: number): number {
30
+ const schedule = parse(rule);
31
+ if (!schedule) throw Error('Choose a supported schedule.');
32
+ const current = new Date(now);
33
+ if (!Number.isFinite(current.getTime())) throw Error('The computer clock is unavailable.');
34
+ if (['MINUTELY', 'HOURLY'].includes(schedule.frequency)) {
35
+ return now + (schedule.frequency === 'MINUTELY' ? 60_000 : 3_600_000) * schedule.interval;
36
+ }
37
+ for (let offset = 0; offset <= 7; offset++) {
38
+ const candidate = new Date(current.getFullYear(), current.getMonth(), current.getDate() + offset, schedule.hour, schedule.minute);
39
+ const day = ['SU', 'MO', 'TU', 'WE', 'TH', 'FR', 'SA'][candidate.getDay()]!;
40
+ if (candidate.getTime() > now && (schedule.days.size === 0 || schedule.days.has(day))) return candidate.getTime();
41
+ }
42
+ throw Error('This schedule has no future occurrence.');
43
+ }
@@ -0,0 +1,76 @@
1
+ import { createHash, randomUUID } from 'node:crypto';
2
+ import { closeSync, fsyncSync, lstatSync, mkdirSync, openSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from 'node:fs';
3
+ import { dirname, join } from 'node:path';
4
+ import { stringify } from 'smol-toml';
5
+ import { TaskSchedulePatch } from '../../../../packages/protocol/task-schedules';
6
+ import type { ScheduleRepositoryOptions, StoredSchedule } from '.';
7
+ import { changeNativeSchedule } from './native-change';
8
+ import { validateScheduleRule } from './cadence';
9
+
10
+ function privateCheckpoint(path: string, bytes: string): void {
11
+ let descriptor: number | undefined;
12
+ try {
13
+ descriptor = openSync(path, 'wx', 0o600);
14
+ writeFileSync(descriptor, bytes); fsyncSync(descriptor);
15
+ }
16
+ catch (error) {
17
+ if ((error as NodeJS.ErrnoException).code !== 'EEXIST' || readFileSync(path, 'utf8') !== bytes) throw error;
18
+ }
19
+ finally { if (descriptor !== undefined) closeSync(descriptor); }
20
+ }
21
+
22
+ function retainRecentCheckpoints(root: string, id: string, retained: string[]): void {
23
+ try {
24
+ const pattern = new RegExp('^' + id + '-[a-f0-9]{64}-(before|proposed-[a-f0-9]{64})\\.toml$');
25
+ const paths = readdirSync(root).filter(name => pattern.test(name)).map(name => join(root, name))
26
+ .filter(path => lstatSync(path).isFile()).sort((a, b) => lstatSync(b).mtimeMs - lstatSync(a).mtimeMs);
27
+ for (const path of paths.slice(20)) if (!retained.includes(path)) rmSync(path);
28
+ } catch { /* Retention cannot turn a successful native save into a failed edit. */ }
29
+ }
30
+
31
+ /** Both the old and proposed configuration survive a failed or concurrent write. */
32
+ export function changeStoredSchedule(stored: StoredSchedule, revision: string, input: TaskSchedulePatch,
33
+ options: ScheduleRepositoryOptions): void {
34
+ if (stored.schedule.revision !== revision) throw Error('This automation changed. Refresh before editing it.');
35
+ if (!stored.schedule.editable) throw Error(stored.schedule.reason ?? 'This automation is read-only.');
36
+ const patch = TaskSchedulePatch.parse(input);
37
+ const needsRule = patch.rrule != null || patch.status === 'ACTIVE' && stored.schedule.status !== 'ACTIVE';
38
+ if (needsRule && !validateScheduleRule(patch.rrule ?? stored.schedule.rrule)) throw Error('Choose a supported schedule.');
39
+ const now = options.now?.() ?? Date.now();
40
+ if (!Number.isSafeInteger(now) || now < 0) throw Error('The computer clock is unavailable.');
41
+ const record = { ...stored.record, ...patch, updated_at: Math.max(now, Number(stored.record.updated_at ?? 0) + 1) };
42
+ const proposed = stringify(record);
43
+ if (Buffer.byteLength(proposed) > 65_536) throw Error('The automation exceeds its native file size limit.');
44
+ mkdirSync(options.backupRoot, { recursive: true, mode: 0o700 });
45
+ const base = stored.schedule.id + '-' + revision;
46
+ const before = join(options.backupRoot, base + '-before.toml');
47
+ const after = join(options.backupRoot, base + '-proposed-' + createHash('sha256').update(proposed).digest('hex') + '.toml');
48
+ privateCheckpoint(before, stored.text);
49
+ privateCheckpoint(after, proposed);
50
+ options.beforeCommit?.();
51
+ changeNativeSchedule(stored, record, { root: options.root, now, writeMirror: () => {
52
+ const latest = lstatSync(stored.path);
53
+ if (latest.isSymbolicLink() || !latest.isFile() || latest.size > 65_536
54
+ || createHash('sha256').update(readFileSync(stored.path)).digest('hex') !== revision) {
55
+ throw Error('This automation changed. Refresh before editing it.');
56
+ }
57
+ replaceFile(stored.path, proposed);
58
+ }, restoreMirror: () => {
59
+ if (readFileSync(stored.path, 'utf8') === proposed) replaceFile(stored.path, stored.text);
60
+ } });
61
+ retainRecentCheckpoints(options.backupRoot, stored.schedule.id, [before, after]);
62
+ }
63
+
64
+ function replaceFile(path: string, text: string): void {
65
+ const temporary = join(dirname(path), '.granttap-schedule-' + randomUUID());
66
+ let descriptor: number | undefined;
67
+ try {
68
+ descriptor = openSync(temporary, 'wx', 0o600);
69
+ writeFileSync(descriptor, text); fsyncSync(descriptor);
70
+ closeSync(descriptor); descriptor = undefined;
71
+ renameSync(temporary, path);
72
+ } finally {
73
+ if (descriptor !== undefined) closeSync(descriptor);
74
+ rmSync(temporary, { force: true });
75
+ }
76
+ }
@@ -0,0 +1,63 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { TaskSchedulesRequest, TaskSchedulesResult } from '../../../../packages/protocol/task-schedules';
3
+ import type { createTaskScheduleRepository } from '.';
4
+
5
+ type Repository = ReturnType<typeof createTaskScheduleRepository>;
6
+ type Delivery = { fingerprint: string; result: TaskSchedulesResult; at: number };
7
+ type Context = { controller: string; sessions: readonly string[]; send: (result: TaskSchedulesResult) => Promise<void> };
8
+
9
+ /** Edits are addressed to an existing native automation and idempotent per paired controller. */
10
+ export class TaskScheduleControls {
11
+ private readonly deliveries = new Map<string, Delivery>();
12
+ constructor(private readonly repository: Repository, private readonly now: () => number = Date.now) {}
13
+
14
+ async handle(input: TaskSchedulesRequest, context: Context): Promise<boolean> {
15
+ const parsed = TaskSchedulesRequest.safeParse(input);
16
+ if (!parsed.success || Math.abs(this.now() - parsed.data.createdAt) > 120_000) return false;
17
+ const request = parsed.data, at = this.now();
18
+ for (const [key, value] of this.deliveries) if (at - value.at > 120_000) this.deliveries.delete(key);
19
+ const key = JSON.stringify([context.controller, request.requestId]);
20
+ const fingerprint = createHash('sha256').update(JSON.stringify(request)).digest('hex');
21
+ const previous = this.deliveries.get(key);
22
+ if (previous) {
23
+ if (previous.fingerprint !== fingerprint) return false;
24
+ await context.send(previous.result); return true;
25
+ }
26
+ if (this.deliveries.size >= 128) {
27
+ await context.send({ ...this.base(request, at), phase: 'unavailable',
28
+ message: 'Too many automation requests. Wait briefly and refresh status.' });
29
+ return true;
30
+ }
31
+ const result = this.prepare(request, context.sessions, at);
32
+ this.deliveries.set(key, { fingerprint, result, at });
33
+ await context.send(result);
34
+ return true;
35
+ }
36
+
37
+ private prepare(request: TaskSchedulesRequest, sessions: readonly string[], at: number): TaskSchedulesResult {
38
+ const base = this.base(request, at);
39
+ if (sessions.length === 0) return { ...base, phase: 'unavailable',
40
+ message: 'Codex automations are unavailable for this chat on this computer.' };
41
+ try {
42
+ const change = request.change;
43
+ const updated = change ? this.repository.change(change.id, change.revision, change.patch, sessions) : undefined;
44
+ const list = this.repository.list(sessions);
45
+ const schedules = updated ? [updated, ...list.schedules.filter(row => row.id !== updated.id)] : [...list.schedules];
46
+ let truncated = list.truncated;
47
+ while (schedules.length > 32 || Buffer.byteLength(JSON.stringify(schedules)) > 60_000) { schedules.pop(); truncated = true; }
48
+ return TaskSchedulesResult.parse({ ...base, schedules, truncated,
49
+ phase: updated && updated.confirmation !== 'confirmed' ? 'waiting' : 'completed' });
50
+ } catch (error) {
51
+ // Parser and native-storage errors can contain private configuration. Return only owned messages.
52
+ const message = error instanceof Error && /^(This automation |Choose a supported schedule\.|The native scheduler is unavailable\.|The computer clock is unavailable\.)/.test(error.message)
53
+ ? error.message.slice(0, 240) : 'The computer could not apply this change. Refresh before retrying.';
54
+ return { ...base, phase: 'failed', message };
55
+ }
56
+ }
57
+
58
+ private base(request: TaskSchedulesRequest, at: number) {
59
+ return { type: 'task.schedules.result' as const, requestId: request.requestId,
60
+ sessionId: request.sessionId, schedules: [], truncated: false,
61
+ timeZone: Intl.DateTimeFormat().resolvedOptions().timeZone, createdAt: at };
62
+ }
63
+ }
@@ -0,0 +1,64 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { isUtf8 } from 'node:buffer';
3
+ import { existsSync, lstatSync, readdirSync, readFileSync } from 'node:fs';
4
+ import { join } from 'node:path';
5
+ import { parse, type TomlTable } from 'smol-toml';
6
+ import { TaskSchedule, TaskSchedulePatch } from '../../../../packages/protocol/task-schedules';
7
+ import { changeStoredSchedule } from './changes';
8
+ import { nativeScheduleState } from './native-state';
9
+
10
+ export type ScheduleRepositoryOptions = { root: string; backupRoot: string; now?: () => number; beforeCommit?: () => void };
11
+ export type StoredSchedule = { path: string; text: string; record: TomlTable; schedule: TaskSchedule };
12
+ const validId = (id: string) => /^[a-zA-Z0-9_-]{1,160}$/.test(id);
13
+
14
+ /** Native Codex heartbeat records; no parallel scheduler or provider execution. */
15
+ export function createTaskScheduleRepository(options: ScheduleRepositoryOptions) {
16
+ const directory = join(options.root, 'automations');
17
+ function read(id: string, sessions: readonly string[]): StoredSchedule | undefined {
18
+ if (!validId(id)) return undefined;
19
+ const folder = join(directory, id), path = join(folder, 'automation.toml');
20
+ try {
21
+ if (!lstatSync(directory).isDirectory() || lstatSync(directory).isSymbolicLink()
22
+ || !lstatSync(folder).isDirectory() || lstatSync(folder).isSymbolicLink()) return undefined;
23
+ const stat = lstatSync(path);
24
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 65_536) return undefined;
25
+ const bytes = readFileSync(path);
26
+ if (!isUtf8(bytes)) return undefined;
27
+ const text = bytes.toString('utf8'), record = parse(text);
28
+ if (record.version !== 1 || record.id !== id || record.kind !== 'heartbeat'
29
+ || typeof record.target_thread_id !== 'string' || !sessions.includes(record.target_thread_id)
30
+ || !Number.isSafeInteger(record.created_at) || Number(record.created_at) < 0
31
+ || !Number.isSafeInteger(record.updated_at) || Number(record.updated_at) < 0) return undefined;
32
+ const parsed = TaskSchedule.safeParse({ id, sessionId: record.target_thread_id,
33
+ name: record.name, prompt: record.prompt, rrule: record.rrule, status: record.status === 'DISABLED' ? 'PAUSED' : record.status,
34
+ revision: createHash('sha256').update(text).digest('hex'), editable: true, confirmation: 'unavailable' });
35
+ if (!parsed.success) return undefined;
36
+ const schedule = { ...parsed.data, ...nativeScheduleState(options.root, parsed.data, record) };
37
+ return { path, text, record, schedule };
38
+ } catch { return undefined; }
39
+ }
40
+ return {
41
+ list(sessions: readonly string[]) {
42
+ if (!existsSync(directory)) return { schedules: [] as TaskSchedule[], truncated: false };
43
+ const names = readdirSync(directory).slice(0, 512);
44
+ const schedules: TaskSchedule[] = [];
45
+ let bytes = 0, truncated = names.length === 512;
46
+ for (const id of names) {
47
+ const item = read(id, sessions)?.schedule;
48
+ if (!item) continue;
49
+ const size = Buffer.byteLength(JSON.stringify(item));
50
+ if (schedules.length === 32 || bytes + size > 60_000) { truncated = true; break; }
51
+ schedules.push(item); bytes += size;
52
+ }
53
+ return { schedules, truncated };
54
+ },
55
+ change(id: string, revision: string, patch: TaskSchedulePatch, sessions: readonly string[]): TaskSchedule {
56
+ const stored = read(id, sessions);
57
+ if (!stored) throw Error('This automation is unavailable in this chat.');
58
+ changeStoredSchedule(stored, revision, patch, options);
59
+ const saved = read(id, sessions);
60
+ if (!saved) throw Error('The native automation changed after saving. Refresh its status.');
61
+ return saved.schedule;
62
+ },
63
+ };
64
+ }
@@ -0,0 +1,43 @@
1
+ import { DatabaseSync, type SQLOutputValue } from 'node:sqlite';
2
+ import type { TomlTable } from 'smol-toml';
3
+ import type { StoredSchedule } from '.';
4
+ import { findNativeSchedule, sameNativeSchedule } from './native-state';
5
+ import { nextNativeOccurrence } from './cadence';
6
+
7
+ function nativeTimestamp(value: SQLOutputValue | undefined): number | null {
8
+ if (value === null) return null;
9
+ if (typeof value === 'number' && Number.isSafeInteger(value) && value >= 0) return value;
10
+ throw Error('The native scheduler is unavailable.');
11
+ }
12
+
13
+ /** Commit the native scheduling state and its mirror together; never start a provider run. */
14
+ export function changeNativeSchedule(stored: StoredSchedule, record: TomlTable,
15
+ commit: { root: string; now: number; writeMirror: () => void; restoreMirror: () => void }): void {
16
+ const store = findNativeSchedule(commit.root, stored.schedule.id);
17
+ if (!store) throw Error('The native scheduler is unavailable.');
18
+ const database = new DatabaseSync(store.path);
19
+ let fileChanged = false;
20
+ try {
21
+ database.exec('PRAGMA foreign_keys=ON; PRAGMA busy_timeout=250; BEGIN IMMEDIATE;');
22
+ const row = database.prepare('SELECT * FROM automations WHERE id=?').get(stored.schedule.id);
23
+ if (!row || !sameNativeSchedule(row, stored.schedule, stored.record)) throw Error('This automation changed. Refresh before editing.');
24
+ const reschedule = record.status === 'ACTIVE'
25
+ && (row.status !== 'ACTIVE' || row.rrule !== record.rrule);
26
+ const next = record.status === 'PAUSED' ? null : reschedule
27
+ ? nextNativeOccurrence(String(record.rrule), commit.now) : nativeTimestamp(row.next_run_at);
28
+ const nominal = record.status === 'PAUSED' ? null : reschedule ? next : nativeTimestamp(row.next_run_nominal_at);
29
+ const result = database.prepare(`UPDATE automations SET name=?,prompt=?,status=?,rrule=?,updated_at=?,
30
+ next_run_at=?,next_run_nominal_at=? WHERE id=? AND updated_at=? AND target_thread_id=?
31
+ AND kind='heartbeat' AND account_id IS NULL AND user_id IS NULL AND installation_id IS NULL`)
32
+ .run(String(record.name), String(record.prompt), String(record.status), String(record.rrule),
33
+ Number(record.updated_at), next, nominal, stored.schedule.id,
34
+ Number(stored.record.updated_at), stored.schedule.sessionId);
35
+ if (result.changes !== 1) throw Error('This automation changed. Refresh before editing.');
36
+ commit.writeMirror(); fileChanged = true;
37
+ database.exec('COMMIT;');
38
+ } catch (error) {
39
+ try { database.exec('ROLLBACK;'); } catch { /* No open transaction after a failed BEGIN. */ }
40
+ if (fileChanged) commit.restoreMirror();
41
+ throw error;
42
+ } finally { database.close(); }
43
+ }
@@ -0,0 +1,63 @@
1
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { DatabaseSync, type SQLOutputValue } from 'node:sqlite';
4
+ import type { TaskSchedule } from '../../../../packages/protocol/task-schedules';
5
+ import type { TomlTable } from 'smol-toml';
6
+
7
+ export type NativeScheduleRow = Record<string, SQLOutputValue>;
8
+ export type NativeScheduleStore = { path: string; row: NativeScheduleRow };
9
+ const columns = `id,name,prompt,status,rrule,updated_at,target_thread_id,kind,
10
+ account_id,user_id,installation_id,next_run_at,next_run_nominal_at`;
11
+
12
+ function localScheduler(root: string): boolean {
13
+ const path = join(root, '.codex-global-state.json');
14
+ if (!existsSync(path)) return true;
15
+ try {
16
+ const stat = lstatSync(path);
17
+ if (!stat.isFile() || stat.isSymbolicLink() || stat.size > 4 * 1024 * 1024) return false;
18
+ const state = JSON.parse(readFileSync(path, 'utf8'));
19
+ const migration = state?.['electron-persisted-atom-state']?.['electron-automations-habitat-migration-state'];
20
+ return migration === undefined || migration != null && typeof migration === 'object'
21
+ && !Array.isArray(migration) && Object.keys(migration).length === 0;
22
+ } catch { return false; }
23
+ }
24
+
25
+ /** Only a single, local native record can be controlled. Cloud ownership is never inferred. */
26
+ export function findNativeSchedule(root: string, id: string): NativeScheduleStore | undefined {
27
+ if (!localScheduler(root)) return undefined;
28
+ const matches: NativeScheduleStore[] = [];
29
+ for (const name of ['codex-dev.db', 'codex.db']) {
30
+ const path = join(root, 'sqlite', name);
31
+ let database: DatabaseSync | undefined;
32
+ try {
33
+ if (![root, join(root, 'sqlite')].every(item => lstatSync(item).isDirectory()
34
+ && !lstatSync(item).isSymbolicLink())) continue;
35
+ if (!existsSync(path) || !lstatSync(path).isFile() || lstatSync(path).isSymbolicLink()) continue;
36
+ database = new DatabaseSync(path, { readOnly: true });
37
+ database.exec('PRAGMA query_only=ON; PRAGMA busy_timeout=50;');
38
+ const rows = database.prepare(`SELECT ${columns} FROM automations WHERE id=? OR legacy_automation_id=? LIMIT 2`).all(id, id);
39
+ for (const row of rows) matches.push({ path, row });
40
+ } catch { /* Locked, unsupported or incomplete stores remain read-only. */ }
41
+ finally { database?.close(); }
42
+ }
43
+ return matches.length === 1 ? matches[0] : undefined;
44
+ }
45
+
46
+ export function sameNativeSchedule(row: NativeScheduleRow, schedule: TaskSchedule, record: TomlTable): boolean {
47
+ return row.id === schedule.id && row.kind === 'heartbeat' && row.account_id == null
48
+ && row.user_id == null && row.installation_id == null && row.name === schedule.name
49
+ && row.prompt === schedule.prompt && row.status === schedule.status && row.rrule === schedule.rrule
50
+ && row.target_thread_id === schedule.sessionId && row.updated_at === record.updated_at;
51
+ }
52
+
53
+ export function nativeScheduleState(root: string, schedule: TaskSchedule, record: TomlTable): Pick<TaskSchedule, 'editable' | 'confirmation' | 'reason'> {
54
+ const store = findNativeSchedule(root, schedule.id);
55
+ if (!store) return { editable: false, confirmation: 'unavailable', reason: 'The native scheduler is unavailable or has an unsupported storage version.' };
56
+ if (store.row.account_id != null || store.row.user_id != null || store.row.installation_id != null) {
57
+ return { editable: false, confirmation: 'unavailable', reason: 'This automation is managed by the Codex account.' };
58
+ }
59
+ if (!sameNativeSchedule(store.row, schedule, record)) {
60
+ return { editable: false, confirmation: 'waiting', reason: 'The native automation changed. Refresh it in Codex before editing.' };
61
+ }
62
+ return { editable: true, confirmation: 'confirmed' };
63
+ }
@@ -0,0 +1,41 @@
1
+ import { homedir } from 'node:os';
2
+ import { isAbsolute, join } from 'node:path';
3
+ import type { RelayClient } from '../../../../packages/core/relay-client';
4
+ import { TaskSchedulesRequest } from '../../../../packages/protocol/task-schedules';
5
+ import { configDir, loadConfig, machineConfigPath } from '../config';
6
+ import { publishedCatalog, publishedSession } from '../session-catalog';
7
+ import { sendSessionPayload } from '../host/session-keys';
8
+ import { createTaskScheduleRepository } from '.';
9
+ import { TaskScheduleControls } from './controls';
10
+
11
+ let current: { root: string; controls: TaskScheduleControls } | undefined;
12
+
13
+ /** Task scope comes from admitted executions. Native paths and peer identity are never phone supplied. */
14
+ export async function handleTaskSchedules(client: RelayClient, input: TaskSchedulesRequest,
15
+ peer: string | undefined): Promise<boolean> {
16
+ const parsed = TaskSchedulesRequest.safeParse(input);
17
+ if (!peer || !parsed.success || Math.abs(Date.now() - parsed.data.createdAt) > 120_000) return false;
18
+ const request = parsed.data;
19
+ try {
20
+ const machine = loadConfig(machineConfigPath());
21
+ if (machine.role !== 'machine' || machine.room !== client.room
22
+ || machine.peerPublicKey !== peer && !machine.extraPeerPublicKeys?.includes(peer)) return false;
23
+ } catch { return false; }
24
+ const session = publishedSession(client, request.sessionId);
25
+ if (!session) return false;
26
+ const catalog = publishedCatalog(client);
27
+ const candidates = [...(catalog?.sessions ?? []), ...(catalog?.history ?? [])];
28
+ const sessions = [...new Set(candidates.filter(item => item.agent === 'codex'
29
+ && (item.sessionId === session.sessionId || session.projectId != null && session.taskId != null
30
+ && item.projectId === session.projectId && item.taskId === session.taskId)).map(item => item.sessionId))];
31
+ const root = process.env.GRANTTAP_CODEX_DIR ?? process.env.NODVOX_CODEX_DIR ?? process.env.CODEX_HOME ?? join(homedir(), '.codex');
32
+ if (!isAbsolute(root)) return false;
33
+ if (current?.root !== root) current = { root, controls: new TaskScheduleControls(createTaskScheduleRepository({
34
+ root, backupRoot: join(configDir(), 'task-schedule-checkpoints'),
35
+ })) };
36
+ return current.controls.handle(request, { controller: JSON.stringify([client.room, peer]), sessions,
37
+ send: result => sendSessionPayload(client, result, request.sessionId, 'phone', {
38
+ peerPublicKey: peer, reliable: false, urgent: true, ttlMs: 30_000,
39
+ }),
40
+ });
41
+ }
@@ -10,7 +10,7 @@ keeps each phone response tied to the exact originating chat and prompt.
10
10
  Install the supported CLI on this computer, then run:
11
11
 
12
12
  ```bash
13
- npm install -g granttap-mcp@0.8.115
13
+ npm install -g granttap-mcp@0.8.116
14
14
  granttap setup
15
15
  granttap status
16
16
  ```
@@ -4,7 +4,7 @@
4
4
  "command": "node",
5
5
  "args": [
6
6
  "-e",
7
- "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.115\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
7
+ "\"use strict\";\n\n// Cursor may start this MCP from any cwd. Execute JavaScript entry points with\n// Node itself: Windows batch shims require shell quoting that differs by host.\nconst { spawn } = require(\"node:child_process\");\nconst { existsSync } = require(\"node:fs\");\nconst { delimiter, dirname, join } = require(\"node:path\");\n\nconst PACKAGE = \"granttap-mcp@0.8.116\";\nconst dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === \"1\";\nconst platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM\n ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;\nconst nodeRoot = dryRun && process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT\n ? process.env.GRANTTAP_BOOTSTRAP_NODE_ROOT : dirname(process.execPath);\nconst pathRoots = (process.env.PATH || \"\").split(delimiter).filter(Boolean);\nconst appData = process.env.APPDATA;\nconst roots = [nodeRoot, ...pathRoots, ...(appData ? [join(appData, \"npm\")] : [])];\nconst first = (paths) => paths.find((path) => existsSync(path));\n\nfunction launch() {\n if (platform !== \"win32\") {\n const installed = first(roots.map((root) => join(root, \"granttap-mcp\")));\n if (installed) return { command: installed, args: [] };\n const npx = first(roots.map((root) => join(root, \"npx\"))) || \"npx\";\n return { command: npx, args: [\"-y\", PACKAGE] };\n }\n const installed = first(roots.map((root) =>\n join(root, \"node_modules\", \"granttap-mcp\", \"bin\", \"granttap-mcp.mjs\")));\n if (installed) return { command: process.execPath, args: [installed] };\n const npx = first(roots.map((root) =>\n join(root, \"node_modules\", \"npm\", \"bin\", \"npx-cli.js\")));\n if (npx) return { command: process.execPath, args: [npx, \"-y\", PACKAGE] };\n throw new Error(\"Node.js/npm 22.13.0+ is required on this Windows device. Install npm and retry GrantTap.\");\n}\n\nlet target;\ntry { target = launch(); }\ncatch (error) {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n}\n\nif (dryRun) {\n process.stdout.write(JSON.stringify(target));\n process.exit(0);\n}\n\nconst child = spawn(target.command, target.args, { stdio: \"inherit\", windowsHide: true });\nchild.on(\"error\", (error) => {\n process.stderr.write(`[granttap] ${error.message}\\n`);\n process.exit(1);\n});\nchild.on(\"exit\", (code, signal) => {\n if (signal && platform !== \"win32\") {\n try { process.kill(process.pid, signal); } catch { process.exit(code ?? 1); }\n return;\n }\n process.exit(code ?? 1);\n});\n"
8
8
  ]
9
9
  }
10
10
  }
@@ -7,7 +7,7 @@ const { spawn } = require("node:child_process");
7
7
  const { existsSync } = require("node:fs");
8
8
  const { delimiter, dirname, join } = require("node:path");
9
9
 
10
- const PACKAGE = "granttap-mcp@0.8.115";
10
+ const PACKAGE = "granttap-mcp@0.8.116";
11
11
  const dryRun = process.env.GRANTTAP_BOOTSTRAP_DRY_RUN === "1";
12
12
  const platform = dryRun && process.env.GRANTTAP_BOOTSTRAP_PLATFORM
13
13
  ? process.env.GRANTTAP_BOOTSTRAP_PLATFORM : process.platform;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "granttap-mcp",
3
- "version": "0.8.115",
3
+ "version": "0.8.116",
4
4
  "description": "Commercial computer runtime connecting local coding agents to GrantTap on Mac, iPhone, iPad and Apple Watch.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -39,6 +39,7 @@
39
39
  "@modelcontextprotocol/sdk": "^1.32.1",
40
40
  "express": "^5.2.1",
41
41
  "qrcode": "^1.5.4",
42
+ "smol-toml": "1.9.1",
42
43
  "tsx": "^4.23.4",
43
44
  "tweetnacl": "^1.0.3",
44
45
  "ws": "^8.18.0",
@@ -2,6 +2,8 @@
2
2
  import { z } from "zod";
3
3
  import { DeviceManagementRequest, DeviceManagementResult } from './messages/device/management';
4
4
  export * from './messages/device/management';
5
+ import { TaskSchedulesRequest, TaskSchedulesResult } from './task-schedules';
6
+ export * from './task-schedules';
5
7
  import { ProjectTransportSet, ProjectTransportAck } from './task-network';
6
8
  export * from './task-network';
7
9
  import { TaskTransferContinue, TaskTransferContinueResult } from "./task-transfer";
@@ -117,6 +119,7 @@ export const MachineHeartbeat = z.object({
117
119
  machine: z.string().catch("machine"),
118
120
  createdAt: z.number().catch(() => Date.now()),
119
121
  taskQueueImmediate: z.boolean().optional(),
122
+ taskScheduleControls: z.boolean().optional(),
120
123
  });
121
124
  export type MachineHeartbeat = z.infer<typeof MachineHeartbeat>;
122
125
 
@@ -124,6 +127,8 @@ export type MachineHeartbeat = z.infer<typeof MachineHeartbeat>;
124
127
  // ZodEffects rather than plain objects. A regular union preserves those checks
125
128
  // while keeping the same strict `type` discriminator on every member.
126
129
  export const Payload = z.union([
130
+ TaskSchedulesRequest,
131
+ TaskSchedulesResult,
127
132
  DeviceManagementRequest,
128
133
  DeviceManagementResult,
129
134
  ApprovalRequest,
@@ -0,0 +1,8 @@
1
+ # Task schedule protocol
2
+
3
+ Public entry: `index.ts`. Strict encrypted messages query or change existing native
4
+ automations for an admitted chat. Names, instructions, recurrence and enabled state
5
+ are separate from provider execution, delivery queues and capability policy.
6
+ Results distinguish an applied change, an unconfirmed native state and failure.
7
+
8
+ License: first-party code is under the GrantTap Commercial Source License in the repository LICENSE.
@@ -0,0 +1,32 @@
1
+ import { z } from 'zod';
2
+
3
+ const identifier = z.string().min(1).max(256);
4
+ export const TaskSchedulePatch = z.object({
5
+ name: z.string().trim().min(1).max(160).optional(),
6
+ prompt: z.string().min(1).max(8192).refine(value => value.trim().length > 0).optional(),
7
+ rrule: z.string().trim().min(1).max(1024).optional(),
8
+ status: z.enum(['ACTIVE', 'PAUSED']).optional(),
9
+ }).strict().refine(value => Object.keys(value).length > 0, 'An edit must change a supported field');
10
+ export type TaskSchedulePatch = z.infer<typeof TaskSchedulePatch>;
11
+ export const TaskSchedule = z.object({
12
+ id: identifier, sessionId: identifier, name: z.string().min(1).max(160),
13
+ prompt: z.string().max(8192), rrule: z.string().max(1024),
14
+ status: z.enum(['ACTIVE', 'PAUSED']), revision: z.string().regex(/^[a-f0-9]{64}$/),
15
+ editable: z.boolean(), confirmation: z.enum(['confirmed', 'waiting', 'unavailable']),
16
+ reason: z.string().max(240).optional(),
17
+ }).strict();
18
+ export type TaskSchedule = z.infer<typeof TaskSchedule>;
19
+ export const TaskSchedulesRequest = z.object({
20
+ type: z.literal('task.schedules'), requestId: identifier, sessionId: identifier,
21
+ createdAt: z.number().finite(),
22
+ change: z.object({ id: identifier, revision: z.string().regex(/^[a-f0-9]{64}$/), patch: TaskSchedulePatch }).strict().optional(),
23
+ }).strict();
24
+ export type TaskSchedulesRequest = z.infer<typeof TaskSchedulesRequest>;
25
+ export const TaskSchedulesResult = z.object({
26
+ type: z.literal('task.schedules.result'), requestId: identifier, sessionId: identifier,
27
+ phase: z.enum(['completed', 'waiting', 'failed', 'unavailable']),
28
+ schedules: z.array(TaskSchedule).max(32), truncated: z.boolean(),
29
+ timeZone: z.string().max(100), message: z.string().max(240).optional(), createdAt: z.number().finite(),
30
+ }).strict().refine(value => new Set(value.schedules.map(row => row.id)).size === value.schedules.length
31
+ && new TextEncoder().encode(JSON.stringify(value)).length <= 65_536);
32
+ export type TaskSchedulesResult = z.infer<typeof TaskSchedulesResult>;