@gajae-code/coding-agent 0.8.0 → 0.8.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.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  ## [Unreleased]
4
4
 
5
+ ## [0.8.1] - 2026-07-04
6
+
7
+ ### Added
8
+
9
+ - Skills can now be invoked inline within a prompt (for example, mid-message `/skill:*` references) instead of only as a standalone prompt, with matching autocomplete, input-controller queueing, and ACP dispatch support.
10
+
11
+ ### Fixed
12
+
13
+ - The Telegram notification daemon now tombstones a session endpoint generation after `session_closed`, preventing the scan loop from reconnecting to the still-live old endpoint and recreating an empty topic immediately after deleting the original topic.
14
+ - `/contribute-pr` in the interactive TUI now prepares the redacted manifest and worker prompt without spawning a second GJC process on the same terminal, avoiding competing TUI renderers that make the chat viewport jump around. Run the generated worker prompt from a separate terminal instead.
15
+ - The `skill` tool is now a default-registered essential tool instead of a discoverable one, so skill chaining is always available without first activating it through tool discovery.
16
+ - The coordinator MCP server now classifies vanished-but-acknowledged tmux turns correctly, avoiding spurious delivery failures when a delegated turn's pane disappears after its prompt was already acknowledged.
17
+ - Tool-call argument strings are now sanitized of invalid Unicode surrogate sequences across providers (Anthropic, Bedrock, Google, OpenAI chat/completions/responses, and Codex), including custom Responses tool input, preventing malformed tool calls from breaking requests.
18
+
5
19
  ## [0.8.0] - 2026-07-04
6
20
 
7
21
  ### Added
@@ -233,6 +233,7 @@ export interface TelegramDaemonOptions {
233
233
  interface SessionSocket {
234
234
  sessionId: string;
235
235
  token: string;
236
+ endpointKey: string;
236
237
  ws: WebSocket;
237
238
  pending: Map<string, {
238
239
  sessionId: string;
@@ -267,6 +268,8 @@ export declare class TelegramNotificationDaemon {
267
268
  private readonly topicOwnerByIdentity;
268
269
  /** Non-identity frames held until identity creates the correct thread. */
269
270
  private readonly pendingThreadedFrames;
271
+ /** Endpoint generation tombstones for sessions that already sent session_closed. */
272
+ private readonly closedEndpointKeys;
270
273
  /** True once the daemon has nudged the user to enable Threaded Mode. */
271
274
  private threadedFallbackNoticeSent;
272
275
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -33,7 +33,7 @@ export declare class SkillTool implements AgentTool<typeof skillSchema, SkillToo
33
33
  readonly name = "skill";
34
34
  readonly label = "Skill";
35
35
  readonly summary = "Chain into another available skill in the current turn";
36
- readonly loadMode = "discoverable";
36
+ readonly loadMode = "essential";
37
37
  readonly description: string;
38
38
  readonly parameters: z.ZodObject<{
39
39
  name: z.ZodString;
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "type": "module",
3
3
  "name": "@gajae-code/coding-agent",
4
- "version": "0.8.0",
4
+ "version": "0.8.1",
5
5
  "description": "Gajae Code CLI with read, bash, edit, write tools and session management",
6
6
  "homepage": "https://gajae-code.com",
7
7
  "author": "Yeachan-Heo",
@@ -52,12 +52,12 @@
52
52
  "@agentclientprotocol/sdk": "0.21.0",
53
53
  "@babel/parser": "^7.29.3",
54
54
  "@mozilla/readability": "^0.6.0",
55
- "@gajae-code/stats": "0.8.0",
56
- "@gajae-code/agent-core": "0.8.0",
57
- "@gajae-code/ai": "0.8.0",
58
- "@gajae-code/natives": "0.8.0",
59
- "@gajae-code/tui": "0.8.0",
60
- "@gajae-code/utils": "0.8.0",
55
+ "@gajae-code/stats": "0.8.1",
56
+ "@gajae-code/agent-core": "0.8.1",
57
+ "@gajae-code/ai": "0.8.1",
58
+ "@gajae-code/natives": "0.8.1",
59
+ "@gajae-code/tui": "0.8.1",
60
+ "@gajae-code/utils": "0.8.1",
61
61
  "@puppeteer/browsers": "^2.13.0",
62
62
  "@types/turndown": "5.0.6",
63
63
  "@xterm/headless": "^6.0.0",
@@ -966,24 +966,53 @@ function hasTmuxIdentity(session: Record<string, unknown>): boolean {
966
966
  );
967
967
  }
968
968
 
969
+ function unavailableSessionReason(turn: TurnRecord, reason: string): string {
970
+ if (
971
+ reason === "tmux_session_missing" &&
972
+ turn.delivery.tmux_keys_sent === true &&
973
+ turn.delivery.prompt_acknowledged === true
974
+ ) {
975
+ return "tmux_session_missing_after_prompt_acknowledgement";
976
+ }
977
+ return reason;
978
+ }
979
+
980
+ function unavailableSessionEvidence(turn: TurnRecord, reason: string, timestamp: string): Record<string, unknown>[] {
981
+ if (reason !== "tmux_session_missing_after_prompt_acknowledgement") return turn.evidence;
982
+ return [
983
+ ...turn.evidence,
984
+ {
985
+ type: reason,
986
+ message:
987
+ "The tmux session disappeared after GJC runtime acknowledged the prompt, before any terminal final_response or error was recorded. Treat this as an in-flight vanished session and inspect/restart with recovery evidence rather than resubmitting blindly.",
988
+ tmux_keys_sent: true,
989
+ prompt_acknowledged: true,
990
+ prior_status: turn.status,
991
+ created_at: timestamp,
992
+ },
993
+ ];
994
+ }
995
+
969
996
  async function markTurnFailedForUnavailableSession(
970
997
  namespaceDir: string,
971
998
  turn: TurnRecord,
972
999
  reason: string,
973
1000
  ): Promise<TurnRecord> {
974
1001
  const timestamp = new Date().toISOString();
1002
+ const durableReason = unavailableSessionReason(turn, reason);
975
1003
  const failed: TurnRecord = {
976
1004
  ...turn,
977
1005
  status: "failed",
978
1006
  final_response: {
979
- text: `Coordinator session unavailable: ${reason}`,
1007
+ text: `Coordinator session unavailable: ${durableReason}`,
980
1008
  format: "markdown",
981
1009
  source: "coordinator_liveness",
982
1010
  artifact_path: null,
983
1011
  truncated: false,
984
1012
  },
985
- error: { code: "session_unavailable", message: reason, recoverable: true },
986
- liveness: { checked_at: timestamp, live: false, reason },
1013
+ evidence: unavailableSessionEvidence(turn, durableReason, timestamp),
1014
+ error: { code: "session_unavailable", message: durableReason, recoverable: true },
1015
+ liveness: { checked_at: timestamp, live: false, reason: durableReason },
987
1016
  updated_at: timestamp,
988
1017
  completed_at: timestamp,
989
1018
  };
@@ -992,7 +1021,7 @@ async function markTurnFailedForUnavailableSession(
992
1021
  await writeSessionState(namespaceDir, failed.session_id, "stale", {
993
1022
  lastTurnId: failed.turn_id,
994
1023
  live: false,
995
- reason,
1024
+ reason: durableReason,
996
1025
  });
997
1026
  return failed;
998
1027
  }
@@ -326,12 +326,28 @@ interface SkillTokenMatch {
326
326
  skill: Skill;
327
327
  }
328
328
 
329
+ function buildInlineSkillInvocationArgs(text: string, matches: readonly SkillTokenMatch[]): string {
330
+ const pieces: string[] = [];
331
+ let cursor = 0;
332
+ for (const match of matches) {
333
+ pieces.push(text.slice(cursor, match.index));
334
+ cursor = match.end;
335
+ }
336
+ pieces.push(text.slice(cursor));
337
+ return pieces
338
+ .join("")
339
+ .replace(/[ \t]{2,}/g, " ")
340
+ .replace(/[ \t]+\n/g, "\n")
341
+ .replace(/\n[ \t]+/g, "\n")
342
+ .trim();
343
+ }
344
+
329
345
  export function parseSkillInvocations(
330
346
  text: string,
331
347
  skillsByCommandName: ReadonlyMap<string, Skill>,
332
348
  ): ParsedSkillInvocation[] {
333
349
  const trimmedText = text.trim();
334
- if (!trimmedText.startsWith("/")) return [];
350
+ if (!trimmedText) return [];
335
351
  const canonicalSkillCommandPattern = /(^|\s)\/(skill:[^\s]+)/g;
336
352
  const matches: SkillTokenMatch[] = [];
337
353
  for (const match of trimmedText.matchAll(canonicalSkillCommandPattern)) {
@@ -348,7 +364,19 @@ export function parseSkillInvocations(
348
364
  skill,
349
365
  });
350
366
  }
351
- if (matches.length === 0 || matches[0]?.index !== 0) return [];
367
+ if (matches.length === 0) return [];
368
+ if (matches[0]?.index !== 0) {
369
+ // Preserve leading slash-command semantics: `/skill:unknown /skill:alpha`
370
+ // should remain plain text rather than silently skipping the unknown
371
+ // leading command and invoking the later known one.
372
+ if (trimmedText.startsWith("/")) return [];
373
+ const args = buildInlineSkillInvocationArgs(trimmedText, matches);
374
+ return matches.map(match => ({
375
+ commandName: match.commandName,
376
+ args,
377
+ skill: match.skill,
378
+ }));
379
+ }
352
380
  return matches.map((match, index) => {
353
381
  const next = matches[index + 1];
354
382
  return {
@@ -710,9 +710,6 @@ export class AcpAgent implements Agent {
710
710
  text: string,
711
711
  options: { directAliasMayCollide?: boolean } = {},
712
712
  ): Promise<boolean> {
713
- if (!text.startsWith("/")) {
714
- return false;
715
- }
716
713
  if (!record.session.skillsSettings?.enableSkillCommands) {
717
714
  return false;
718
715
  }
@@ -758,9 +755,13 @@ export class AcpAgent implements Agent {
758
755
  }
759
756
  return true;
760
757
  }
761
- const spaceIndex = text.indexOf(" ");
762
- const commandName = spaceIndex === -1 ? text.slice(1) : text.slice(1, spaceIndex);
763
- const args = spaceIndex === -1 ? "" : text.slice(spaceIndex + 1).trim();
758
+ const slashText = text.trimStart();
759
+ if (!slashText.startsWith("/")) {
760
+ return false;
761
+ }
762
+ const spaceIndex = slashText.indexOf(" ");
763
+ const commandName = spaceIndex === -1 ? slashText.slice(1) : slashText.slice(1, spaceIndex);
764
+ const args = spaceIndex === -1 ? "" : slashText.slice(spaceIndex + 1).trim();
764
765
  if (
765
766
  !isNamespacedSkillSlashCommandName(commandName) &&
766
767
  options.directAliasMayCollide !== true &&
@@ -1276,17 +1276,18 @@ export class CommandController {
1276
1276
  async handleContributionPrepCommand(customInstructions?: string): Promise<void> {
1277
1277
  this.ctx.editor.setText("");
1278
1278
  try {
1279
- const result = await this.ctx.session.prepareContributionPrep({ customInstructions, spawnWorker: true });
1279
+ const result = await this.ctx.session.prepareContributionPrep({ customInstructions, spawnWorker: false });
1280
1280
  this.ctx.showStatus(
1281
1281
  [
1282
1282
  "Contribution prep artifacts written.",
1283
1283
  `Manifest: ${result.manifestPath}`,
1284
1284
  `Worker prompt: ${result.workerPromptPath}`,
1285
+ "Open the worker prompt from a separate terminal to keep this TUI session stable.",
1285
1286
  ].join("\n"),
1286
1287
  );
1287
1288
  this.ctx.chatContainer.addChild(
1288
1289
  new Text(
1289
- `${theme.fg("accent", `${theme.status.success} Contribution prep ready`)}\nManifest: ${result.manifestPath}`,
1290
+ `${theme.fg("accent", `${theme.status.success} Contribution prep ready`)}\nWorker prompt: ${result.workerPromptPath}`,
1290
1291
  1,
1291
1292
  1,
1292
1293
  ),
@@ -644,19 +644,18 @@ export class InputController {
644
644
 
645
645
  /**
646
646
  * Dispatch skill slash invocation(s) (`/skill:<name>`) through custom messages
647
- * using the supplied `streamingBehavior`. Returns true if the text was a
648
- * recognised skill command chain and was dispatched. A failure to load a skill
649
- * file is surfaced via `showError` but still returns true — the editor was
650
- * already cleared on the success path, so falling through to plain-text
651
- * handling at that point would double-submit. Returns false when the text
652
- * isn't a `/skill:` prefix or the command name isn't a registered skill,
653
- * so the caller can fall through to plain-text handling (this branch
647
+ * using the supplied `streamingBehavior`. Returns true if the text contains a
648
+ * recognised canonical skill command or command chain and was dispatched. A
649
+ * failure to load a skill file is surfaced via `showError` but still returns
650
+ * true — the editor was already cleared on the success path, so falling
651
+ * through to plain-text handling at that point would double-submit. Returns
652
+ * false when the text has no registered canonical skill invocation, so the
653
+ * caller can fall through to plain-text handling (this branch
654
654
  * leaves the editor state untouched). `streamingBehavior` is only consulted
655
655
  * while the agent is streaming; the idle path of `promptCustomMessage`
656
656
  * ignores it.
657
657
  */
658
658
  async #invokeSkillCommand(text: string, streamingBehavior: "steer" | "followUp"): Promise<boolean> {
659
- if (!text.startsWith("/")) return false;
660
659
  const invocations = parseSkillInvocations(text, this.ctx.skillCommands ?? new Map());
661
660
  if (invocations.length === 0) return false;
662
661
  this.ctx.editor.addToHistory(text);
@@ -177,6 +177,9 @@ function getSlashTokenPrefix(textBeforeCursor: string): string | null {
177
177
  return token;
178
178
  }
179
179
 
180
+ function isLeadingSlashToken(textBeforeCursor: string, slashPrefix: string): boolean {
181
+ return textBeforeCursor.trimStart() === slashPrefix;
182
+ }
180
183
  export class PromptActionAutocompleteProvider implements AutocompleteProvider {
181
184
  #baseProvider: CombinedAutocompleteProvider;
182
185
  #actions: PromptActionDefinition[];
@@ -223,10 +226,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
223
226
 
224
227
  const slashPrefix = getSlashTokenPrefix(textBeforeCursor);
225
228
  if (slashPrefix) {
226
- const baseSuggestions = withoutSkillCommandSuggestions(
227
- await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol),
228
- );
229
- const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor);
229
+ const isLeading = isLeadingSlashToken(textBeforeCursor, slashPrefix);
230
+ const baseSuggestions = isLeading
231
+ ? withoutSkillCommandSuggestions(await this.#baseProvider.getSuggestions(lines, cursorLine, cursorCol))
232
+ : null;
233
+ const skillCommandSuggestions =
234
+ isLeading || slashPrefix.startsWith("/skill")
235
+ ? this.#getSkillCommandSuggestions(textBeforeCursor, { includeEmpty: isLeading })
236
+ : null;
230
237
  return sortSlashCommandSuggestions(
231
238
  mergeAutocompleteSuggestions(baseSuggestions, skillCommandSuggestions),
232
239
  this.#commands,
@@ -300,7 +307,7 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
300
307
  const baseSuggestions = withoutSkillCommandSuggestions(
301
308
  this.#baseProvider.trySyncSlashCompletion?.(textBeforeCursor) ?? null,
302
309
  );
303
- const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor);
310
+ const skillCommandSuggestions = this.#getSkillCommandSuggestions(textBeforeCursor, { includeEmpty: false });
304
311
  return sortSlashCommandSuggestions(
305
312
  mergeAutocompleteSuggestions(baseSuggestions, skillCommandSuggestions),
306
313
  this.#commands,
@@ -311,11 +318,14 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
311
318
  return tryEmojiInlineReplace(textBeforeCursor);
312
319
  }
313
320
 
314
- #getSkillCommandSuggestions(textBeforeCursor: string): { items: AutocompleteItem[]; prefix: string } | null {
321
+ #getSkillCommandSuggestions(
322
+ textBeforeCursor: string,
323
+ options: { includeEmpty: boolean },
324
+ ): { items: AutocompleteItem[]; prefix: string } | null {
315
325
  const prefix = getSlashTokenPrefix(textBeforeCursor);
316
326
  if (!prefix) return null;
317
327
  const query = prefix.slice(1).toLowerCase();
318
- if (query.length === 0) return null;
328
+ if (query.length === 0 && !options.includeEmpty) return null;
319
329
  const normalizedQuery = query.startsWith("skill-") ? `skill:${query.slice("skill-".length)}` : query;
320
330
  const exactNonSkillCommand = this.#commands.some(
321
331
  command => command.name === query && !command.name.startsWith("skill:"),
@@ -142,6 +142,9 @@ function splitTelegramPlainText(text: string, max = TELEGRAM_MESSAGE_LIMIT): str
142
142
  if (out) chunks.push(out);
143
143
  return chunks;
144
144
  }
145
+ function endpointGenerationKey(url: string, token: string): string {
146
+ return `${url}\0${token}`;
147
+ }
145
148
 
146
149
  /**
147
150
  * Whether `err` is a transient network failure worth retrying. Telegram API
@@ -772,6 +775,7 @@ export interface TelegramDaemonOptions {
772
775
  interface SessionSocket {
773
776
  sessionId: string;
774
777
  token: string;
778
+ endpointKey: string;
775
779
  ws: WebSocket;
776
780
  pending: Map<string, { sessionId: string; actionId: string }>;
777
781
  /** True once the server advertised the `client_ping_pong` capability. */
@@ -808,6 +812,8 @@ export class TelegramNotificationDaemon {
808
812
  private readonly topicOwnerByIdentity = new Map<string, string>();
809
813
  /** Non-identity frames held until identity creates the correct thread. */
810
814
  private readonly pendingThreadedFrames = new Map<string, PendingThreadedFrame[]>();
815
+ /** Endpoint generation tombstones for sessions that already sent session_closed. */
816
+ private readonly closedEndpointKeys = new Map<string, string>();
811
817
  /** True once the daemon has nudged the user to enable Threaded Mode. */
812
818
  private threadedFallbackNoticeSent = false;
813
819
  /** Sessions whose identity header was already sent flat (Threaded Mode off). */
@@ -1223,7 +1229,9 @@ export class TelegramNotificationDaemon {
1223
1229
  matches: msg => msg.type === "session_closed",
1224
1230
  handle: async session => {
1225
1231
  this.busy.delete(session.sessionId);
1232
+ this.closedEndpointKeys.set(session.sessionId, session.endpointKey);
1226
1233
  await this.deleteTopic(session.sessionId);
1234
+ this.dropSession(session, "session_closed");
1227
1235
  },
1228
1236
  });
1229
1237
  }
@@ -1260,6 +1268,9 @@ export class TelegramNotificationDaemon {
1260
1268
  // would chase a dead, token-bearing record forever.
1261
1269
  const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
1262
1270
  if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
1271
+ const endpointKey = endpointGenerationKey(endpoint.url, endpoint.token);
1272
+ if (this.closedEndpointKeys.get(sessionId) === endpointKey) continue;
1273
+ this.closedEndpointKeys.delete(sessionId);
1263
1274
  this.connectSession(sessionId, endpoint.url, endpoint.token);
1264
1275
  } catch {}
1265
1276
  }
@@ -1269,9 +1280,12 @@ export class TelegramNotificationDaemon {
1269
1280
  connectSession(sessionId: string, url: string, token: string): void {
1270
1281
  const WS = this.opts.WebSocketImpl ?? WebSocket;
1271
1282
  const ws = new WS(`${url}/?token=${encodeURIComponent(token)}`);
1283
+ const endpointKey = endpointGenerationKey(url, token);
1284
+ this.closedEndpointKeys.delete(sessionId);
1272
1285
  const session: SessionSocket = {
1273
1286
  sessionId,
1274
1287
  token,
1288
+ endpointKey,
1275
1289
  ws,
1276
1290
  pending: new Map(),
1277
1291
  capable: false,
@@ -50,7 +50,7 @@ export class SkillTool implements AgentTool<typeof skillSchema, SkillToolDetails
50
50
  readonly name = "skill";
51
51
  readonly label = "Skill";
52
52
  readonly summary = "Chain into another available skill in the current turn";
53
- readonly loadMode = "discoverable";
53
+ readonly loadMode = "essential";
54
54
  readonly description: string;
55
55
  readonly parameters = skillSchema;
56
56
  readonly strict = true;