Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
30 changes: 28 additions & 2 deletions packages/skill/plugins/claude-code/hooks/register.ts
Original file line number Diff line number Diff line change
Expand Up @@ -615,12 +615,38 @@ export function nodespaceInvocations(command: string): string[][] {
endWord()

const invocations: string[][] = []
/** What earlier plain assignments in the line set: `id=<uuid>; nodespace node context $id`. */
const assigned = new Map<string, string>()
const expand = (token: string): string => {
const name = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/.exec(token)

return assigned.get(name?.[1] ?? name?.[2] ?? '') ?? token
}

for (const segment of segments) {
const start = segment.findIndex(token => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
const head = segment[start] ?? ''

if (start < 0 || (head !== 'nodespace' && !head.endsWith('/nodespace'))) {
// A segment of assignments alone sets variables for what follows; one in
// front of a command is scoped to that command. A value that is still
// an expansion (`$(...)`, a backtick, a name not set here) is unknown:
// the name is forgotten rather than kept as that text.
if (start < 0) {
for (const token of segment) {
const split = token.indexOf('=')
const value = expand(token.slice(split + 1))

if (/[$`]/.test(value)) {
assigned.delete(token.slice(0, split))
} else {
assigned.set(token.slice(0, split), value)
}
}

continue
}

if (head !== 'nodespace' && !head.endsWith('/nodespace')) {
continue
}

Expand All @@ -632,7 +658,7 @@ export function nodespaceInvocations(command: string): string[][] {
if (token === '--database' || token === '--socket') {
i += 1
} else if (token !== '--json' && !/^--(database|socket)=/.test(token)) {
words.push(token)
words.push(expand(token))
}
}

Expand Down
17 changes: 17 additions & 0 deletions packages/skill/plugins/claude-code/tests/register.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1168,6 +1168,23 @@ describe('reading the shell line and the remote', () => {
expect(nodespaceInvocations('git status')).toEqual([])
})

test('reads a variable set earlier in the line as what it was set to, and no further', () => {
expect(nodespaceInvocations('id=t1; nodespace node context $id | head')).toEqual([['node', 'context', 't1']])
expect(nodespaceInvocations('ID=t1 && nodespace node context "${ID}"')).toEqual([['node', 'context', 't1']])
// The last assignment wins, and one variable can be set from another.
expect(nodespaceInvocations('id=t1; id=t2; nodespace node context $id')).toEqual([['node', 'context', 't2']])
expect(nodespaceInvocations('a=t1; b=$a; nodespace node context $b')).toEqual([['node', 'context', 't1']])
// What cannot be known stays as written: a prefix assignment is scoped to its own command,
// a command's output and `export` are not read, and a name never set here is not guessed.
expect(nodespaceInvocations('id=t1 nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('export id=t1; nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('id=$(nodespace node create x); nodespace node context $id')).toEqual([
['node', 'create', 'x'],
['node', 'context', '$id'],
])
})

test("the lookup names the checkout's own remote when it is none of the common spellings", () => {
const remote = 'ssh://git@gitlab.example.com:2222/group/repo.git\n'
const spellings = remoteSpellings(httpsRemote(remote) ?? '', remote)
Expand Down
30 changes: 28 additions & 2 deletions packages/skill/plugins/shared/nodespace-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -428,12 +428,38 @@ export function nodespaceInvocations(command: string): string[][] {
endWord()

const invocations: string[][] = []
/** What earlier plain assignments in the line set: `id=<uuid>; nodespace node context $id`. */
const assigned = new Map<string, string>()
const expand = (token: string): string => {
const name = /^\$(?:\{([A-Za-z_][A-Za-z0-9_]*)\}|([A-Za-z_][A-Za-z0-9_]*))$/.exec(token)

return assigned.get(name?.[1] ?? name?.[2] ?? '') ?? token
}

for (const segment of segments) {
const start = segment.findIndex(token => !/^[A-Za-z_][A-Za-z0-9_]*=/.test(token))
const head = segment[start] ?? ''

if (start < 0 || (head !== 'nodespace' && !head.endsWith('/nodespace'))) {
// A segment of assignments alone sets variables for what follows; one in
// front of a command is scoped to that command. A value that is still
// an expansion (`$(...)`, a backtick, a name not set here) is unknown:
// the name is forgotten rather than kept as that text.
if (start < 0) {
for (const token of segment) {
const split = token.indexOf('=')
const value = expand(token.slice(split + 1))

if (/[$`]/.test(value)) {
assigned.delete(token.slice(0, split))
} else {
assigned.set(token.slice(0, split), value)
}
}

continue
}

if (head !== 'nodespace' && !head.endsWith('/nodespace')) {
continue
}

Expand All @@ -445,7 +471,7 @@ export function nodespaceInvocations(command: string): string[][] {
if (token === '--database' || token === '--socket') {
i += 1
} else if (token !== '--json' && !/^--(database|socket)=/.test(token)) {
words.push(token)
words.push(expand(token))
}
}

Expand Down
35 changes: 34 additions & 1 deletion packages/skill/src/tests/plugin-session.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
import { describe, it, expect, afterEach, beforeEach, vi } from 'vitest';
import nodespacePi from '../../plugins/pi/index';
import { NodeSpace as nodespaceOpenCode } from '../../plugins/opencode/nodespace';
import { createSession, httpsRemote } from '../../plugins/shared/nodespace-session';
import { createSession, httpsRemote, nodespaceInvocations } from '../../plugins/shared/nodespace-session';
import type { Host } from '../../plugins/shared/nodespace-session';
import { CONSENT_RULES, ORIENTATION } from '../shipped-text.js';

Expand Down Expand Up @@ -403,6 +403,39 @@ describe('a session, whichever harness it runs in', () => {
expect(w.calls).toHaveLength(before + 1);
});

it('watches an item the context read names through a variable set earlier in the line', async () => {
const w = world();
const session = createSession(hostOf(w), 60_000);
await session.start('/work/widgets', { sessionId: 'ses_1' });
await session.afterTool('id=t1; nodespace node context $id | head -40', '');
expect(commands(w).slice(-1)).toEqual(['node context t1']);

await session.afterTool('ID=t1 && nodespace node context "${ID}"', '');
expect(commands(w).slice(-1)).toEqual(['node context t1']);

w.item = { id: 't1', version: 4, title: 'Add the gauge', properties: { status: 'done' } };
w.contextVersion = 'c2';
w.now += 61_000;
expect(await session.beforeTool()).toMatchObject({ deny: expect.stringContaining('changed under it') });
});

it('reads a variable set earlier in the line as what it was set to, and no further', () => {
expect(nodespaceInvocations('id=t1; nodespace node context $id | head')).toEqual([['node', 'context', 't1']])
expect(nodespaceInvocations('ID=t1 && nodespace node context "${ID}"')).toEqual([['node', 'context', 't1']])
// The last assignment wins, and one variable can be set from another.
expect(nodespaceInvocations('id=t1; id=t2; nodespace node context $id')).toEqual([['node', 'context', 't2']])
expect(nodespaceInvocations('a=t1; b=$a; nodespace node context $b')).toEqual([['node', 'context', 't1']])
// What cannot be known stays as written: a prefix assignment is scoped to its own command,
// a command's output and `export` are not read, and a name never set here is not guessed.
expect(nodespaceInvocations('id=t1 nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('export id=t1; nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('nodespace node context $id')).toEqual([['node', 'context', '$id']])
expect(nodespaceInvocations('id=$(nodespace node create x); nodespace node context $id')).toEqual([
['node', 'create', 'x'],
['node', 'context', '$id'],
])
});

// A harness whose hooks are one process per event (Codex) keeps the state in
// a file: a session rebuilt from its snapshot is the session it was.
it('behaves the same when each call runs on a session rebuilt from the last one\'s snapshot', async () => {
Expand Down
48 changes: 48 additions & 0 deletions scripts/eval/harness/pi.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { describe, expect, test } from "bun:test";
import { records } from "./pi.ts";

/** A byte stream that delivers `chunks` as they are given, however they cut the text. */
async function* chunked(...chunks: string[]): AsyncGenerator<Uint8Array> {
const encoder = new TextEncoder();

for (const chunk of chunks) {
yield encoder.encode(chunk);
}
}

async function all(stream: AsyncIterable<Uint8Array>): Promise<string[]> {
const out: string[] = [];

for await (const line of records(stream)) {
out.push(line);
}

return out;
}

describe("Pi's JSONL framing", () => {
test("splits on LF only, across chunks, and strips a CR", async () => {
expect(await all(chunked('{"a":1}\r\n{"b"', ':2}\n{"c":3}\n'))).toEqual(['{"a":1}', '{"b":2}', '{"c":3}']);
});

test("keeps a Unicode line or paragraph separator inside its record", async () => {
const record = JSON.stringify({ text: "one
two
three" });

expect(await all(chunked(`${record}\n`))).toEqual([record]);
});

test("keeps a multi-byte character cut between chunks", async () => {
const bytes = new TextEncoder().encode('{"t":"é"}\n');
const cut = bytes.indexOf(0xc3) + 1;
const stream = (async function* () {
yield bytes.slice(0, cut);
yield bytes.slice(cut);
})();

expect(await all(stream)).toEqual(['{"t":"é"}']);
});

test("yields a last record that has no newline", async () => {
expect(await all(chunked('{"a":1}\n{"b":2}'))).toEqual(['{"a":1}', '{"b":2}']);
});
});
Loading